Repository: POPWorldMedia/POPForums Branch: main Commit: 64c16a52cb15 Files: 575 Total size: 1.9 MB Directory structure: gitextract_3gvf_42o/ ├── .github/ │ └── workflows/ │ └── codeql.yml ├── .gitignore ├── CLAUDE.md ├── CODE_OF_CONDUCT.md ├── LICENSE.txt ├── NuGet.config ├── PopForums.sln ├── PopForums.sln.DotSettings ├── README.md ├── SECURITY.md ├── docs/ │ ├── _config.yml │ ├── architecture.md │ ├── azurekitlibrary.md │ ├── customization.md │ ├── elastickitlibrary.md │ ├── externalloginconfig.md │ ├── faq.md │ ├── features.md │ ├── index.md │ ├── multitenant.md │ ├── oauthonly.md │ ├── scoringgame.md │ ├── starthere.md │ └── versionhistory.md └── src/ ├── .editorconfig ├── PopForums/ │ ├── Composers/ │ │ ├── ForumStateComposer.cs │ │ ├── PrivateMessageStateComposer.cs │ │ ├── ResourceComposer.cs │ │ └── TopicStateComposer.cs │ ├── Configuration/ │ │ ├── Config.cs │ │ ├── ConfigContainer.cs │ │ ├── ConfigLoader.cs │ │ ├── ErrorLog.cs │ │ ├── ErrorLogException.cs │ │ ├── ErrorSeverity.cs │ │ ├── ICacheHelper.cs │ │ ├── Settings.cs │ │ └── SettingsManager.cs │ ├── Email/ │ │ ├── EmailQueuePayload.cs │ │ ├── EmailQueuePayloadType.cs │ │ ├── EmailWorker.cs │ │ ├── ForgotPasswordMailer.cs │ │ ├── MailingListComposer.cs │ │ ├── NewAccountMailer.cs │ │ ├── SmtpStatusCode.cs │ │ └── SmtpWrapper.cs │ ├── Extensions/ │ │ ├── ServiceCollections.cs │ │ ├── Streams.cs │ │ ├── Strings.cs │ │ └── Users.cs │ ├── ExternalLogin/ │ │ ├── ExternalAuthenticationResult.cs │ │ ├── ExternalLoginInfo.cs │ │ ├── ExternalUserAssociation.cs │ │ ├── ExternalUserAssociationManager.cs │ │ └── ExternalUserAssociationMatchResult.cs │ ├── Feeds/ │ │ └── FeedService.cs │ ├── Global.cs │ ├── Messaging/ │ │ ├── IBroker.cs │ │ ├── Models/ │ │ │ ├── AwardData.cs │ │ │ ├── AwardPayload.cs │ │ │ ├── QuestionData.cs │ │ │ ├── ReplyData.cs │ │ │ ├── ReplyPayload.cs │ │ │ └── VoteData.cs │ │ ├── Notification.cs │ │ ├── NotificationAdapter.cs │ │ ├── NotificationManager.cs │ │ ├── NotificationTunnel.cs │ │ └── NotificationType.cs │ ├── Models/ │ │ ├── AwardCalculationPayload.cs │ │ ├── BasicJsonMessage.cs │ │ ├── BasicServiceResponse.cs │ │ ├── CategorizedForumContainer.cs │ │ ├── Category.cs │ │ ├── CategoryContainerWithForums.cs │ │ ├── ClientPrivateMessagePost.cs │ │ ├── DisplayProfile.cs │ │ ├── EmailMessage.cs │ │ ├── ErrorLogEntry.cs │ │ ├── ExpiredUserSession.cs │ │ ├── FeedEvent.cs │ │ ├── Forum.cs │ │ ├── ForumPermissionContainer.cs │ │ ├── ForumPermissionContext.cs │ │ ├── ForumState.cs │ │ ├── ForumTopicContainer.cs │ │ ├── IPHistoryEvent.cs │ │ ├── IStreamResponse.cs │ │ ├── Ignore.cs │ │ ├── ModerationLogEntry.cs │ │ ├── ModerationType.cs │ │ ├── ModifyForumRolesContainer.cs │ │ ├── ModifyForumRolesType.cs │ │ ├── NewPost.cs │ │ ├── PagedListOfT.cs │ │ ├── PagedTopicContainer.cs │ │ ├── PagerContext.cs │ │ ├── PasswordResetContainer.cs │ │ ├── PermanentRoles.cs │ │ ├── Post.cs │ │ ├── PostEdit.cs │ │ ├── PostImage.cs │ │ ├── PostImagePersistPayload.cs │ │ ├── PostItemContainer.cs │ │ ├── PostWithChildren.cs │ │ ├── PrivateMessage.cs │ │ ├── PrivateMessageBoxType.cs │ │ ├── PrivateMessagePost.cs │ │ ├── PrivateMessageState.cs │ │ ├── PrivateMessageUser.cs │ │ ├── PrivateMessageView.cs │ │ ├── Profile.cs │ │ ├── QAPostItemContainer.cs │ │ ├── QueuedEmailMessage.cs │ │ ├── ReadStatus.cs │ │ ├── ResponseOfT.cs │ │ ├── SearchIndexPayload.cs │ │ ├── SearchType.cs │ │ ├── SearchWord.cs │ │ ├── SecurityLogEntry.cs │ │ ├── SecurityLogType.cs │ │ ├── ServiceHeartbeat.cs │ │ ├── SetupVariables.cs │ │ ├── SignupData.cs │ │ ├── SingleString.cs │ │ ├── SubscribeNotificationPayload.cs │ │ ├── TimeFormats.cs │ │ ├── Topic.cs │ │ ├── TopicContainer.cs │ │ ├── TopicContainerForQA.cs │ │ ├── TopicState.cs │ │ ├── TopicUnsubscribeContainer.cs │ │ ├── User.cs │ │ ├── UserEdit.cs │ │ ├── UserEditProfile.cs │ │ ├── UserEditSecurity.cs │ │ ├── UserImage.cs │ │ ├── UserImageApprovalContainer.cs │ │ ├── UserResult.cs │ │ ├── UserSearch.cs │ │ └── VotePostContainer.cs │ ├── PopForums.csproj │ ├── Repositories/ │ │ ├── IAwardCalculationQueueRepository.cs │ │ ├── IAwardConditionRepository.cs │ │ ├── IAwardDefinitionRepository.cs │ │ ├── IBanRepository.cs │ │ ├── ICategoryRepository.cs │ │ ├── IEmailQueueRepository.cs │ │ ├── IErrorLogRepository.cs │ │ ├── IEventDefinitionRepository.cs │ │ ├── IExternalUserAssociationRepository.cs │ │ ├── IFavoriteTopicsRepository.cs │ │ ├── IFeedRepository.cs │ │ ├── IForumRepository.cs │ │ ├── IIgnoreRepository.cs │ │ ├── ILastReadRepository.cs │ │ ├── IModerationLogRepository.cs │ │ ├── INotificationRepository.cs │ │ ├── IPointLedgerRepository.cs │ │ ├── IPostImageRepository.cs │ │ ├── IPostImageTempRepository.cs │ │ ├── IPostRepository.cs │ │ ├── IPrivateMessageRepository.cs │ │ ├── IProfileRepository.cs │ │ ├── IQueuedEmailMessageRepository.cs │ │ ├── IRoleRepository.cs │ │ ├── ISearchIndexQueueRepository.cs │ │ ├── ISearchRepository.cs │ │ ├── ISecurityLogRepository.cs │ │ ├── IServiceHeartbeatRepository.cs │ │ ├── ISettingsRepository.cs │ │ ├── ISetupRepository.cs │ │ ├── ISubscribeNotificationRepository.cs │ │ ├── ISubscribedTopicsRepository.cs │ │ ├── ITopicRepository.cs │ │ ├── ITopicViewLogRepository.cs │ │ ├── IUserAvatarRepository.cs │ │ ├── IUserAwardRepository.cs │ │ ├── IUserImageRepository.cs │ │ ├── IUserRepository.cs │ │ └── IUserSessionRepository.cs │ ├── Resources/ │ │ ├── Resources.Designer.cs │ │ ├── Resources.de.resx │ │ ├── Resources.es.resx │ │ ├── Resources.fr.resx │ │ ├── Resources.nl.resx │ │ ├── Resources.resx │ │ ├── Resources.uk.resx │ │ └── Resources.zh-TW.resx │ ├── ScoringGame/ │ │ ├── AwardCalculator.cs │ │ ├── AwardCalculatorWorker.cs │ │ ├── AwardCondition.cs │ │ ├── AwardDefinition.cs │ │ ├── AwardDefinitionService.cs │ │ ├── EventDefinition.cs │ │ ├── EventDefinitionService.cs │ │ ├── EventPublisher.cs │ │ ├── PointLedgerEntry.cs │ │ ├── UserAward.cs │ │ └── UserAwardService.cs │ └── Services/ │ ├── BanService.cs │ ├── CategoryService.cs │ ├── ClaimsToRoleMapper.cs │ ├── CloseAgedTopicsWorker.cs │ ├── FavoriteTopicService.cs │ ├── ForumPermissionService.cs │ ├── ForumService.cs │ ├── IPHistoryService.cs │ ├── ITopicViewCountService.cs │ ├── IUserRetrievalShim.cs │ ├── IgnoreService.cs │ ├── ImageService.cs │ ├── LastReadService.cs │ ├── MailingListService.cs │ ├── ModerationLogService.cs │ ├── PostImageCleanupWorker.cs │ ├── PostImageService.cs │ ├── PostMasterService.cs │ ├── PostService.cs │ ├── PrivateMessageService.cs │ ├── ProfileService.cs │ ├── QueuedEmailService.cs │ ├── ReCaptchaService.cs │ ├── SearchIndexSubsystem.cs │ ├── SearchIndexWorker.cs │ ├── SearchService.cs │ ├── SecurityLogService.cs │ ├── ServiceHeartbeatService.cs │ ├── SetupService.cs │ ├── SitemapService.cs │ ├── SubscribeNotificationWorker.cs │ ├── SubscribedTopicsService.cs │ ├── TenantService.cs │ ├── TextParsingService.cs │ ├── TimeFormatStringService.cs │ ├── TopicService.cs │ ├── TopicViewLogService.cs │ ├── UserEmailReconciler.cs │ ├── UserNameReconciler.cs │ ├── UserService.cs │ ├── UserSessionService.cs │ └── UserSessionWorker.cs ├── PopForums.AzureKit/ │ ├── Logging/ │ │ └── ErrorLogRepository.cs │ ├── PopForums.AzureKit.csproj │ ├── PostImage/ │ │ └── PostImageRepository.cs │ ├── Queue/ │ │ ├── AwardCalculationQueueRepository.cs │ │ ├── EmailQueueRepository.cs │ │ ├── SearchIndexQueueRepository.cs │ │ └── SubscribeNotificationRepository.cs │ ├── Redis/ │ │ ├── CacheHelper.cs │ │ ├── CacheTelemetrySink.cs │ │ └── ICacheTelemetry.cs │ ├── Search/ │ │ ├── SearchIndexSubsystem.cs │ │ ├── SearchRepository.cs │ │ └── SearchTopic.cs │ └── ServiceCollectionExtensions.cs ├── PopForums.AzureKit.Functions/ │ ├── .gitignore │ ├── AwardCalculationProcessor.cs │ ├── BrokerSink.cs │ ├── CacheHelper.cs │ ├── CloseAgedTopicsProcessor.cs │ ├── EmailProcessor.cs │ ├── NotificationTunnel.cs │ ├── PopForums.AzureKit.Functions.csproj │ ├── PostImageCleanupProcessor.cs │ ├── Program.cs │ ├── SearchIndexProcessor.cs │ ├── SubscribeNotificationProcessor.cs │ ├── UserSessionProcessor.cs │ └── host.json ├── PopForums.ElasticKit/ │ ├── PopForums.ElasticKit.csproj │ ├── Search/ │ │ ├── ElasticSearchClientWrapper.cs │ │ ├── SearchIndexSubsystem.cs │ │ ├── SearchRepository.cs │ │ └── SearchTopic.cs │ └── ServiceCollectionExtensions.cs ├── PopForums.Mvc/ │ ├── Areas/ │ │ └── Forums/ │ │ ├── Authentication/ │ │ │ ├── PopForumsAuthenticationDefaults.cs │ │ │ ├── PopForumsAuthenticationIgnoreAttribute.cs │ │ │ └── PopForumsAuthenticationMiddleware.cs │ │ ├── Authorization/ │ │ │ ├── OAuthOnlyForbidAttribute.cs │ │ │ ├── PopForumsPrivateForumsFilter.cs │ │ │ └── PopForumsUserAttribute.cs │ │ ├── BackgroundJobs/ │ │ │ ├── AwardCalculatorJob.cs │ │ │ ├── CloseAgedTopicsJob.cs │ │ │ ├── EmailJob.cs │ │ │ ├── PostImageCleanupJob.cs │ │ │ ├── SearchIndexJob.cs │ │ │ ├── SubscribeNotificationJob.cs │ │ │ └── UserSessionJob.cs │ │ ├── Controllers/ │ │ │ ├── AccountController.cs │ │ │ ├── AdminApiController.cs │ │ │ ├── AdminController.cs │ │ │ ├── ApiController.cs │ │ │ ├── FavoritesController.cs │ │ │ ├── ForumController.cs │ │ │ ├── HomeController.cs │ │ │ ├── IdentityController.cs │ │ │ ├── IgnoreController.cs │ │ │ ├── ImageController.cs │ │ │ ├── ModeratorController.cs │ │ │ ├── PrivateMessagesController.cs │ │ │ ├── ResourcesController.cs │ │ │ ├── SearchController.cs │ │ │ ├── SetupController.cs │ │ │ ├── SitemapController.cs │ │ │ └── SubscriptionController.cs │ │ ├── Extensions/ │ │ │ ├── ApplicationBuilders.cs │ │ │ ├── AuthorizationOptionsExtensions.cs │ │ │ ├── Logger.cs │ │ │ ├── LoggerFactories.cs │ │ │ ├── LoggerProvider.cs │ │ │ ├── ServiceCollections.cs │ │ │ └── WebApplications.cs │ │ ├── ForumRouteConstraint.cs │ │ ├── Messaging/ │ │ │ ├── Broker.cs │ │ │ ├── PopForumsHub.cs │ │ │ └── PopForumsUserIdProvider.cs │ │ ├── Models/ │ │ │ ├── AwardConditionDeleteContainer.cs │ │ │ ├── EmailUsersContainer.cs │ │ │ ├── ExternalLoginState.cs │ │ │ ├── ExternalLoginTypeMetadata.cs │ │ │ ├── IPHistoryQuery.cs │ │ │ ├── ManualEvent.cs │ │ │ ├── SecurityLogQuery.cs │ │ │ ├── UserEditPhoto.cs │ │ │ ├── UserEditWithFiles.cs │ │ │ └── UserState.cs │ │ ├── Services/ │ │ │ ├── ExternalLoginRoutingService.cs │ │ │ ├── ExternalLoginTempService.cs │ │ │ ├── ForumAdapterFactory.cs │ │ │ ├── IForumAdapter.cs │ │ │ ├── OAuthOnlyService.cs │ │ │ ├── TopicViewCountService.cs │ │ │ ├── UserRetrievalShim.cs │ │ │ └── UserStateComposer.cs │ │ ├── TagHelpers/ │ │ │ ├── ForumReadIndicatorTagHelper.cs │ │ │ ├── PMReadIndicatorTagHelper.cs │ │ │ ├── PagerLinksTagHelper.cs │ │ │ ├── TopicReadIndicatorTagHelper.cs │ │ │ └── ValidationClassTagHelper.cs │ │ ├── ViewComponents/ │ │ │ ├── UserNavigationViewComponent.cs │ │ │ └── UserStateViewComponent.cs │ │ └── Views/ │ │ ├── Account/ │ │ │ ├── AccountCreated.cshtml │ │ │ ├── Create.cshtml │ │ │ ├── EditAccountNoUser.cshtml │ │ │ ├── EditProfile.cshtml │ │ │ ├── ExternalLogins.cshtml │ │ │ ├── Forgot.cshtml │ │ │ ├── Login.cshtml │ │ │ ├── ManagePhotos.cshtml │ │ │ ├── MiniProfile.cshtml │ │ │ ├── MiniUserNotFound.cshtml │ │ │ ├── OAuthLogin.cshtml │ │ │ ├── Posts.cshtml │ │ │ ├── ResetPassword.cshtml │ │ │ ├── ResetPasswordSuccess.cshtml │ │ │ ├── Security.cshtml │ │ │ ├── Unsubscribe.cshtml │ │ │ ├── UnsubscribeFailure.cshtml │ │ │ ├── Verify.cshtml │ │ │ ├── VerifyFail.cshtml │ │ │ └── ViewProfile.cshtml │ │ ├── Admin/ │ │ │ └── App.cshtml │ │ ├── Favorites/ │ │ │ └── Topics.cshtml │ │ ├── Forum/ │ │ │ ├── Edit.cshtml │ │ │ ├── Index.cshtml │ │ │ ├── IndexQA.cshtml │ │ │ ├── ModeratorPanel.cshtml │ │ │ ├── NewComment.cshtml │ │ │ ├── NewReply.cshtml │ │ │ ├── NewTopic.cshtml │ │ │ ├── PostItem.cshtml │ │ │ ├── QAPost.cshtml │ │ │ ├── Recent.cshtml │ │ │ ├── Topic.cshtml │ │ │ ├── TopicPage.cshtml │ │ │ ├── TopicQA.cshtml │ │ │ └── Voters.cshtml │ │ ├── Home/ │ │ │ └── Index.cshtml │ │ ├── Identity/ │ │ │ ├── ExternalError.cshtml │ │ │ └── ExternalLoginCallback.cshtml │ │ ├── Ignore/ │ │ │ └── List.cshtml │ │ ├── Moderator/ │ │ │ ├── PostModerationLog.cshtml │ │ │ └── TopicModerationLog.cshtml │ │ ├── PrivateMessages/ │ │ │ ├── Archive.cshtml │ │ │ ├── Create.cshtml │ │ │ ├── Index.cshtml │ │ │ └── View.cshtml │ │ ├── Search/ │ │ │ └── Index.cshtml │ │ ├── Setup/ │ │ │ ├── Exception.cshtml │ │ │ ├── Index.cshtml │ │ │ ├── NoConnection.cshtml │ │ │ └── Success.cshtml │ │ ├── Shared/ │ │ │ ├── Components/ │ │ │ │ ├── UserNavigation/ │ │ │ │ │ └── Default.cshtml │ │ │ │ └── UserState/ │ │ │ │ └── Default.cshtml │ │ │ ├── Forbidden.cshtml │ │ │ ├── NotFound.cshtml │ │ │ └── PopForumsMaster.cshtml │ │ ├── Subscription/ │ │ │ └── Topics.cshtml │ │ └── _ViewImports.cshtml │ ├── Client/ │ │ ├── Components/ │ │ │ ├── AnswerButton.ts │ │ │ ├── CommentButton.ts │ │ │ ├── FavoriteButton.ts │ │ │ ├── FormattedTime.ts │ │ │ ├── FullText.ts │ │ │ ├── HomeUpdater.ts │ │ │ ├── LoginForm.ts │ │ │ ├── MorePostsBeforeReplyButton.ts │ │ │ ├── MorePostsButton.ts │ │ │ ├── NotificationItem.ts │ │ │ ├── NotificationList.ts │ │ │ ├── NotificationMarkAllButton.ts │ │ │ ├── NotificationToggle.ts │ │ │ ├── PMCount.ts │ │ │ ├── PMForm.ts │ │ │ ├── PostMiniProfile.ts │ │ │ ├── PostModerationLogButton.ts │ │ │ ├── PreviewButton.ts │ │ │ ├── PreviousPostsButton.ts │ │ │ ├── QuoteButton.ts │ │ │ ├── ReplyButton.ts │ │ │ ├── ReplyForm.ts │ │ │ ├── SearchNavForm.ts │ │ │ ├── SubscribeButton.ts │ │ │ ├── TopicButton.ts │ │ │ ├── TopicForm.ts │ │ │ ├── TopicModerationLogButton.ts │ │ │ └── VoteCount.ts │ │ ├── Declarations.ts │ │ ├── ElementBase.ts │ │ ├── Models/ │ │ │ ├── Notification.ts │ │ │ ├── PrivateMessage.ts │ │ │ └── PrivateMessageUser.ts │ │ ├── Services/ │ │ │ ├── LocalizationService.ts │ │ │ ├── MessagingService.ts │ │ │ └── NotificationService.ts │ │ ├── State/ │ │ │ ├── ForumState.ts │ │ │ ├── Localizations.ts │ │ │ ├── PrivateMessageState.ts │ │ │ ├── TopicState.ts │ │ │ └── UserState.ts │ │ ├── StateBase.ts │ │ ├── WatchPropertyAttribute.ts │ │ └── tsconfig.json │ ├── Global.cs │ ├── PopForums.Mvc.csproj │ ├── gulpfile.js │ ├── package.json │ └── wwwroot/ │ ├── Admin.js │ ├── Editor.css │ └── PopForums.css ├── PopForums.Sql/ │ ├── CacheHelper.cs │ ├── Extensions.cs │ ├── Global.cs │ ├── ISqlObjectFactory.cs │ ├── JsonElementTypeHandler.cs │ ├── PopForums.Sql.csproj │ ├── PopForums.sql │ ├── PopForums13to14.sql │ ├── PopForums14to15.sql │ ├── PopForums15to16.sql │ ├── PopForums16to21.sql │ ├── PopForums19to20.sql │ ├── PopForums20to21.sql │ ├── PopForums21to22.sql │ ├── Repositories/ │ │ ├── AwardCalculationQueueRepository.cs │ │ ├── AwardConditionRepository.cs │ │ ├── AwardDefinitionRepository.cs │ │ ├── BanRepository.cs │ │ ├── CategoryRepository.cs │ │ ├── EmailQueueRepository.cs │ │ ├── ErrorLogRepository.cs │ │ ├── EventDefinitionRepository.cs │ │ ├── ExternalUserAssociationRepository.cs │ │ ├── FavoriteTopicsRepository.cs │ │ ├── FeedRepository.cs │ │ ├── ForumRepository.cs │ │ ├── IgnoreRepository.cs │ │ ├── LastReadRepository.cs │ │ ├── ModerationLogRepository.cs │ │ ├── NotificationRepository.cs │ │ ├── PointLedgerRepository.cs │ │ ├── PostImageRepository.cs │ │ ├── PostImageTempRepository.cs │ │ ├── PostRepository.cs │ │ ├── PrivateMessageRepository.cs │ │ ├── ProfileRepository.cs │ │ ├── QueuedEmailMessageRepository.cs │ │ ├── RoleRepository.cs │ │ ├── SearchIndexQueueRepository.cs │ │ ├── SearchRepository.cs │ │ ├── SecurityLogRepository.cs │ │ ├── ServiceHeartbeatRepository.cs │ │ ├── SettingsRepository.cs │ │ ├── SetupRepository.cs │ │ ├── SubscribeNotificationRepository.cs │ │ ├── SubscribedTopicsRepository.cs │ │ ├── TopicRepository.cs │ │ ├── TopicViewLogRepository.cs │ │ ├── UserAvatarRepository.cs │ │ ├── UserAwardRepository.cs │ │ ├── UserImageRepository.cs │ │ ├── UserRepository.cs │ │ └── UserSessionRepository.cs │ ├── SqlObjectFactory.cs │ └── StreamResponse.cs ├── PopForums.Test/ │ ├── Composers/ │ │ ├── ForumStateComposerTests.cs │ │ ├── PrivateMessageStateComposerTests.cs │ │ └── TopicStateComposerTests.cs │ ├── Configuration/ │ │ └── SettingsTests.cs │ ├── Email/ │ │ ├── EmailWorkerTests.cs │ │ └── NewAccountMailerTests.cs │ ├── Extensions/ │ │ └── StringTests.cs │ ├── ExternalLogin/ │ │ └── ExternalUserAssociationManagerTests.cs │ ├── Global.cs │ ├── Messaging/ │ │ ├── NotificationAdapterTests.cs │ │ └── NotificationManagerTests.cs │ ├── Models/ │ │ ├── ForumHomeContainerTests.cs │ │ ├── UserEditSecurityTests.cs │ │ └── UserTest.cs │ ├── Mvc/ │ │ ├── Authorization/ │ │ │ └── PopForumsPrivateForumsFilterTests.cs │ │ ├── Controllers/ │ │ │ ├── AccountControllerTests.cs │ │ │ └── AdminApiControllerTests.cs │ │ └── Services/ │ │ └── OAuthOnlyServiceTests.cs │ ├── PopForums.Test.csproj │ ├── ScoringGame/ │ │ ├── AwardCalculatorTests.cs │ │ ├── AwardCalculatorWorkerTests.cs │ │ ├── AwardDefinitionServiceTests.cs │ │ ├── EventDefintionServiceTests.cs │ │ ├── EventPublisherTests.cs │ │ ├── FeedServiceTests.cs │ │ └── UserAwardServiceTests.cs │ └── Services/ │ ├── BanServiceTests.cs │ ├── CategoryServiceTests.cs │ ├── ClaimsToRoleMapperTests.cs │ ├── CloseAgedTopicsWorkerTests.cs │ ├── FavoriteTopicServiceTests.cs │ ├── ForumPermissionServiceTests.cs │ ├── ForumServiceTests.cs │ ├── ImageServiceTests.cs │ ├── LastReadServiceTests.cs │ ├── PostImageCleanupWorkerTests.cs │ ├── PostImageServiceTests.cs │ ├── PostMasterServiceTests.cs │ ├── PostServiceTests.cs │ ├── PrivateMessageServiceTests.cs │ ├── ProfileServiceTests.cs │ ├── QueuedEmailServiceTests.cs │ ├── SearchIndexWorkerTests.cs │ ├── SearchServiceTests.cs │ ├── SecurityLogServiceTests.cs │ ├── SetupServiceTests.cs │ ├── SitemapServiceTests.cs │ ├── SubscribeNotificationWorkerTests.cs │ ├── SubscribedTopicsServiceTests.cs │ ├── TextParsingServiceCleanForumCodeTests.cs │ ├── TextParsingServiceClientHtmlToForumCodeTests.cs │ ├── TextParsingServiceForumCodeToHtmlTests.cs │ ├── TextParsingServiceOtherTests.cs │ ├── TopicServiceTests.cs │ ├── TopicViewLogServiceTests.cs │ ├── UserEmailReconcilerTests.cs │ ├── UserNameReconcilerTests.cs │ ├── UserServiceTests.cs │ ├── UserSessionServiceTests.cs │ └── UserSessionWorkerTests.cs └── PopForums.Web/ ├── Controllers/ │ └── HomeController.cs ├── PopForums.Web.csproj ├── Program.cs ├── Properties/ │ └── launchSettings.json ├── Views/ │ ├── Home/ │ │ └── Index.cshtml │ ├── Shared/ │ │ └── _Layout.cshtml │ ├── _ViewImports.cshtml │ └── _ViewStart.cshtml └── appsettings.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/codeql.yml ================================================ # For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. # # ******** NOTE ******** # We have attempted to detect the languages in your repository. Please check # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # name: "CodeQL Advanced" on: push: branches: [ "main" ] pull_request: branches: [ "main" ] schedule: - cron: '18 0 * * 5' jobs: analyze: name: Analyze (${{ matrix.language }}) # Runner size impacts CodeQL analysis time. To learn more, please see: # - https://gh.io/recommended-hardware-resources-for-running-codeql # - https://gh.io/supported-runners-and-hardware-resources # - https://gh.io/using-larger-runners (GitHub.com only) # Consider using larger runners or machines with greater resources for possible analysis time improvements. runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} permissions: # required for all workflows security-events: write # required to fetch internal or private CodeQL packs packages: read # only required for workflows in private repositories actions: read contents: read strategy: fail-fast: false matrix: include: - language: actions build-mode: none - language: csharp build-mode: none - language: javascript-typescript build-mode: none # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' # Use `c-cpp` to analyze code written in C, C++ or both # Use 'java-kotlin' to analyze code written in Java, Kotlin or both # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository uses: actions/checkout@v4 # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` # or others). This is typically only required for manual builds. # - name: Setup runtime (example) # uses: actions/setup-example@v1 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality # If the analyze step fails for one of the languages you are analyzing with # "We were unable to automatically build your code", modify the matrix above # to set the build mode to "manual" for that language. Then modify this step # to build your code. # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - if: matrix.build-mode == 'manual' shell: bash run: | echo 'If you are using a "manual" build mode for one or more of the' \ 'languages you are analyzing, replace this with the commands to build' \ 'your code, for example:' echo ' make bootstrap' echo ' make release' exit 1 - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 with: category: "/language:${{matrix.language}}" ================================================ FILE: .gitignore ================================================ packages /.vs/config /.vs /*.user *.user .idea .idea/.idea.PopForums/.idea .idea/.idea.PopForums/riderModule.iml .DS_Store bin obj packages *.pubxml node_modules src/PopForums.AzureKit.Functions/Properties/ServiceDependencies src/PopForums.Mvc/wwwroot/lib/* src/PopForums.Mvc/wwwroot/PopForums.js src/PopForums.Mvc/package-lock.json src/PopForums.Mvc/node_modules src/PopForums.Web/Areas/Forums /src/PopForums.Web/appsettings.development.json /src/PopForums.AzureKit.Functions/local.settings.dev.json /.claude .claude/settings.local.json ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview POP Forums is an ASP.NET Core forum and Q&A application targeting .NET 10. It uses SignalR for real-time updates, TypeScript for front-end components, and SQL Server as the primary data store. ## Solution Structure | Project | Purpose | |---|---| | `PopForums` | Core business logic, service interfaces, repository interfaces, models | | `PopForums.Sql` | SQL Server data access implementations, caching layer, migrations scripts | | `PopForums.Mvc` | ASP.NET MVC area (`/Forums`), controllers, views, TypeScript client, CSS | | `PopForums.Web` | Host app template — references above projects, contains `Program.cs` | | `PopForums.AzureKit` | Azure-specific implementations: Redis cache, Azure Search, Blob Storage, queues | | `PopForums.AzureKit.Functions` | Azure Functions implementations for background jobs | | `PopForums.ElasticKit` | ElasticSearch search implementation | | `PopForums.Test` | xUnit tests using NSubstitute, covers services and some MVC code | ## Build Commands ### .NET ```bash # Build entire solution dotnet build PopForums.sln # Run all tests dotnet test src/PopForums.Test/PopForums.Test.csproj # Run a single test class dotnet test src/PopForums.Test/PopForums.Test.csproj --filter "FullyQualifiedName~PostMasterServiceTests" # Run a single test method dotnet test src/PopForums.Test/PopForums.Test.csproj --filter "FullyQualifiedName~PostMasterServiceTests.SomeMethodName" ``` ### Front-end asset setup (run once from `src/PopForums.Mvc/`) ```bash npm install npx gulp copies # copy node_modules assets (Bootstrap, SignalR, TinyMCE, Vue, etc.) to wwwroot/lib npx gulp css # minify CSS ``` TypeScript compilation is handled automatically by `Microsoft.TypeScript.MSBuild` as part of the .NET build — no manual `tsc` or `gulp ts` needed. The Mvc project's static assets (JS, CSS, fonts) are embedded into the NuGet package and served to the host app via `StaticWebAssetBasePath=/PopForums`. The app itself is run from `PopForums.Web`, not `PopForums.Mvc`. ## Running Locally The `PopForums.Web` project is the host application. Key setup steps: 1. Set the connection string in `appsettings.json` under `PopForums:Database:ConnectionString` (default looks for a local SQL Server DB named `popforums21`) 2. First run: navigate to `/Forums/Setup` to initialize the database and admin account (don't run the SQL script manually before this) 3. Background jobs: by default `Program.cs` uses `AddPopForumsAzureFunctionsAndQueues()`. For local development without Azure, switch to `AddPopForumsBackgroundJobs()` (in-process) ### Docker services for local dev ```bash # SQL Server (ARM: azure-sql-edge; x86: mssql/server:2022-latest) docker run --cap-add SYS_PTRACE -e 'ACCEPT_EULA=1' -e 'MSSQL_SA_PASSWORD=P@ssw0rd' -p 1433:1433 --name sqledge -d mcr.microsoft.com/azure-sql-edge # Azurite (storage + queues) docker run -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite # Redis (distributed cache / SignalR backplane) docker run --name some-redis -p 6379:6379 -d redis # ElasticSearch docker run --name es-9 -p 9200:9200 -e discovery.type=single-node -it docker.elastic.co/elasticsearch/elasticsearch:9.3.0 ``` ## Architecture Patterns ### Layered dependency direction `PopForums.Mvc` → `PopForums` (interfaces) ← `PopForums.Sql` (implementations) - `PopForums` defines all repository interfaces (`IForumRepository`, `IPostRepository`, etc.) in `Repositories/` and service classes in `Services/` - `PopForums.Sql` implements those repository interfaces with SQL Server + Dapper-style access - Services depend on repository interfaces; they never touch SQL directly - `PopForums.AzureKit` and `PopForums.ElasticKit` swap in alternative implementations via DI extension methods ### DI registration pattern Each library exposes extension methods on `IServiceCollection`: - `services.AddPopForumsSql()` — registers SQL repos and in-memory cache - `services.AddPopForumsMvc()` — registers MVC services, auth, and base forum services - `services.AddPopForumsRedisCache()` — overrides cache with Redis two-level cache - `services.AddPopForumsElasticSearch()` / `services.AddPopForumsAzureSearch()` — override search - `services.AddPopForumsAzureFunctionsAndQueues()` — routes background work to Azure queues - `services.AddPopForumsBackgroundJobs()` — runs background jobs in-process (local dev) ### MVC Area All forum routes are under the `Forums` area. Controllers live in `src/PopForums.Mvc/Areas/Forums/Controllers/`. The area is mapped via `app.AddPopForumsEndpoints()`. ### Background jobs Background tasks (email, search indexing, award calculation, session cleanup, etc.) are implemented as `BackgroundService` derivatives. In production, these run as Azure Functions via `PopForums.AzureKit.Functions`. In-process mode is available for single-node or local use. ### Front-end - No SPA framework for the main forum UI — raw TypeScript components in `Client/Components/` - Components extend `ElementBase.ts` and use a simple state engine in `State/` - SignalR connects on page load; components react to hub messages for real-time updates - Vue.js + Vue Router are used **only** for the admin interface - Localization on the client side uses a JSON payload from the server; see `FormattedTime.ts` for an example ### Testing - Tests are in `PopForums.Test`, mirroring the folder structure of the projects under test - Mocking via NSubstitute; test framework is xUnit - Tests focus on service layer; controller and repository coverage is minimal ## Database - Initial schema: `src/PopForums.Sql/PopForums.sql` - Migration scripts follow the pattern `PopForumsXXtoYY.sql` in `src/PopForums.Sql/` - From v21.x to v22.x, run `PopForums21to22.sql` - Statistics (post counts, etc.) are precomputed at write time rather than aggregated at query time ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at jeff@popw.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ ================================================ FILE: LICENSE.txt ================================================ POP Forums Copyright (c)1999-2023, POP World Media, LLC and Jeffrey M. Putz https://popw.com/ https://jeffputz.com/ TRANSLATIONS PROVIDED BY: Spanish: Mauricio Atanache Dutch: Steven van Deursen Ukrainian: Nazar Harasym German: Manfred Theis Taiwanese Mandarin: Cheng Liu MIT LICENSE: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. OTHER LICENSES: NSubstitute: BSD 3-Clause .NET Core, ASP.NET Core, Bootstrap, popper.js, MailKit, TinyMCE: MIT SignalR, xunit: Apache 2.0 ImageSharp: Apache 2.0 as long as you meet the open source and/or revenue stipulations indicated in their license: https://github.com/SixLabors/ImageSharp/blob/main/LICENSE ================================================ FILE: NuGet.config ================================================  ================================================ FILE: PopForums.sln ================================================  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 16.0.28803.156 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{044AC2DE-7D9D-4C1A-8C83-38AF448B5A61}" ProjectSection(SolutionItems) = preProject .gitignore = .gitignore CODE_OF_CONDUCT.md = CODE_OF_CONDUCT.md LICENSE.txt = LICENSE.txt NuGet.config = NuGet.config README.md = README.md CLAUDE.md = CLAUDE.md EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PopForums.Web", "src\PopForums.Web\PopForums.Web.csproj", "{78900BC9-3B9B-42C1-9112-8412A7AACFB7}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PopForums", "src\PopForums\PopForums.csproj", "{1787FE89-F023-482E-ABBA-996554DD332D}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PopForums.Sql", "src\PopForums.Sql\PopForums.Sql.csproj", "{229CDD35-A4A5-44F2-8937-61AB286D6A77}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PopForums.Test", "src\PopForums.Test\PopForums.Test.csproj", "{AB4006F4-F457-490B-B9FC-8DE9CDA67127}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PopForums.AzureKit", "src\PopForums.AzureKit\PopForums.AzureKit.csproj", "{C3E4460C-E785-4FF4-AFAF-07AF2EA8812D}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PopForums.Mvc", "src\PopForums.Mvc\PopForums.Mvc.csproj", "{0AE33580-28F6-41CB-B757-56E02394F645}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PopForums.ElasticKit", "src\PopForums.ElasticKit\PopForums.ElasticKit.csproj", "{20986460-15F5-4C08-BAE2-226F12B1CB23}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PopForums.AzureKit.Functions", "src\PopForums.AzureKit.Functions\PopForums.AzureKit.Functions.csproj", "{D50E69CC-5070-41DD-A881-20D29B1913EE}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution CI|Any CPU = CI|Any CPU Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {78900BC9-3B9B-42C1-9112-8412A7AACFB7}.CI|Any CPU.ActiveCfg = Release|Any CPU {78900BC9-3B9B-42C1-9112-8412A7AACFB7}.CI|Any CPU.Build.0 = Release|Any CPU {78900BC9-3B9B-42C1-9112-8412A7AACFB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {78900BC9-3B9B-42C1-9112-8412A7AACFB7}.Debug|Any CPU.Build.0 = Debug|Any CPU {78900BC9-3B9B-42C1-9112-8412A7AACFB7}.Release|Any CPU.ActiveCfg = Release|Any CPU {78900BC9-3B9B-42C1-9112-8412A7AACFB7}.Release|Any CPU.Build.0 = Release|Any CPU {1787FE89-F023-482E-ABBA-996554DD332D}.CI|Any CPU.ActiveCfg = Release|Any CPU {1787FE89-F023-482E-ABBA-996554DD332D}.CI|Any CPU.Build.0 = Release|Any CPU {1787FE89-F023-482E-ABBA-996554DD332D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1787FE89-F023-482E-ABBA-996554DD332D}.Debug|Any CPU.Build.0 = Debug|Any CPU {1787FE89-F023-482E-ABBA-996554DD332D}.Release|Any CPU.ActiveCfg = Release|Any CPU {1787FE89-F023-482E-ABBA-996554DD332D}.Release|Any CPU.Build.0 = Release|Any CPU {229CDD35-A4A5-44F2-8937-61AB286D6A77}.CI|Any CPU.ActiveCfg = Release|Any CPU {229CDD35-A4A5-44F2-8937-61AB286D6A77}.CI|Any CPU.Build.0 = Release|Any CPU {229CDD35-A4A5-44F2-8937-61AB286D6A77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {229CDD35-A4A5-44F2-8937-61AB286D6A77}.Debug|Any CPU.Build.0 = Debug|Any CPU {229CDD35-A4A5-44F2-8937-61AB286D6A77}.Release|Any CPU.ActiveCfg = Release|Any CPU {229CDD35-A4A5-44F2-8937-61AB286D6A77}.Release|Any CPU.Build.0 = Release|Any CPU {AB4006F4-F457-490B-B9FC-8DE9CDA67127}.CI|Any CPU.ActiveCfg = Debug|Any CPU {AB4006F4-F457-490B-B9FC-8DE9CDA67127}.CI|Any CPU.Build.0 = Debug|Any CPU {AB4006F4-F457-490B-B9FC-8DE9CDA67127}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AB4006F4-F457-490B-B9FC-8DE9CDA67127}.Debug|Any CPU.Build.0 = Debug|Any CPU {AB4006F4-F457-490B-B9FC-8DE9CDA67127}.Release|Any CPU.ActiveCfg = Release|Any CPU {AB4006F4-F457-490B-B9FC-8DE9CDA67127}.Release|Any CPU.Build.0 = Release|Any CPU {C3E4460C-E785-4FF4-AFAF-07AF2EA8812D}.CI|Any CPU.ActiveCfg = Debug|Any CPU {C3E4460C-E785-4FF4-AFAF-07AF2EA8812D}.CI|Any CPU.Build.0 = Debug|Any CPU {C3E4460C-E785-4FF4-AFAF-07AF2EA8812D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C3E4460C-E785-4FF4-AFAF-07AF2EA8812D}.Debug|Any CPU.Build.0 = Debug|Any CPU {C3E4460C-E785-4FF4-AFAF-07AF2EA8812D}.Release|Any CPU.ActiveCfg = Release|Any CPU {C3E4460C-E785-4FF4-AFAF-07AF2EA8812D}.Release|Any CPU.Build.0 = Release|Any CPU {0AE33580-28F6-41CB-B757-56E02394F645}.CI|Any CPU.ActiveCfg = Debug|Any CPU {0AE33580-28F6-41CB-B757-56E02394F645}.CI|Any CPU.Build.0 = Debug|Any CPU {0AE33580-28F6-41CB-B757-56E02394F645}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0AE33580-28F6-41CB-B757-56E02394F645}.Debug|Any CPU.Build.0 = Debug|Any CPU {0AE33580-28F6-41CB-B757-56E02394F645}.Release|Any CPU.ActiveCfg = Release|Any CPU {0AE33580-28F6-41CB-B757-56E02394F645}.Release|Any CPU.Build.0 = Release|Any CPU {20986460-15F5-4C08-BAE2-226F12B1CB23}.CI|Any CPU.ActiveCfg = Debug|Any CPU {20986460-15F5-4C08-BAE2-226F12B1CB23}.CI|Any CPU.Build.0 = Debug|Any CPU {20986460-15F5-4C08-BAE2-226F12B1CB23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {20986460-15F5-4C08-BAE2-226F12B1CB23}.Debug|Any CPU.Build.0 = Debug|Any CPU {20986460-15F5-4C08-BAE2-226F12B1CB23}.Release|Any CPU.ActiveCfg = Release|Any CPU {20986460-15F5-4C08-BAE2-226F12B1CB23}.Release|Any CPU.Build.0 = Release|Any CPU {D50E69CC-5070-41DD-A881-20D29B1913EE}.CI|Any CPU.ActiveCfg = Debug|Any CPU {D50E69CC-5070-41DD-A881-20D29B1913EE}.CI|Any CPU.Build.0 = Debug|Any CPU {D50E69CC-5070-41DD-A881-20D29B1913EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D50E69CC-5070-41DD-A881-20D29B1913EE}.Debug|Any CPU.Build.0 = Debug|Any CPU {D50E69CC-5070-41DD-A881-20D29B1913EE}.Release|Any CPU.ActiveCfg = Release|Any CPU {D50E69CC-5070-41DD-A881-20D29B1913EE}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {DC971413-E75E-4D0A-8189-B71EE5DBAED1} EndGlobalSection EndGlobal ================================================ FILE: PopForums.sln.DotSettings ================================================  IP PM PMID QA UI True ================================================ FILE: README.md ================================================ ![POP Forums logo](https://avatars2.githubusercontent.com/u/8217691?s=200&v=4) POP Forums ========= A forum and Q&A application with real-time updating, image uploading and private message chat in multiple languages. The main branch is now the work-in-progress for future versions running on .NET 10+. The v22.x branch is v22.x, running on .NET 10. If you're looking for the version that works on .NET Framework 4.5+ with MVC 5, check out v13.0.2. Roadmap: v22 is another iterative release, with the biggest feature being an ignore list. I've observed fast page rendering, average 19ms on Azure App Service P0v3 and SQL elastic pool at 50 eDTUs and 900k posts. Future versions will consider issues in the backlog. For the latest information and documentation, and how to get started, check the pages (also in markdown in `/docs` of source): https://popworldmedia.github.io/POPForums/ CI build of main runs here: https://popforumsdev.azurewebsites.net/Forums [![Build status](https://dev.azure.com/popw/POP%20Forums/_apis/build/status/popforumsdev)](https://dev.azure.com/popw/POP%20Forums/_build/latest?definitionId=13) Latest release: https://github.com/POPWorldMedia/POPForums/releases/tag/v22.0.0 Packages available on NuGet. The latest CI build packages can be found with these feeds on MyGet: https://www.myget.org/F/popforums/api/v3/index.json Sample app using only the packages: https://github.com/POPWorldMedia/POPForums.Sample ## Prerequisites: * .NET v10. * npm and Node.js to build the front-end. * AzureKit optionally requires Redis for two-level cache, Azure Search for Search. * AzureKit optionally requires an Azure Storage account for image storage, queues and Azure Functions. * ElasticKit optionally requires ElasticSearch for search. * Works great on Windows, Mac and Linux. * Build with Visual Studio or JetBrains Rider. ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions Use this section to tell people about which versions of your project are currently being supported with security updates. | Version | Supported | | ----------- | ------------------ | | 17.x | :white_check_mark: | | 17.99.0.x | :x: | | < 17.0 | :x: | ## Reporting a Vulnerability If you find something suboptimal, add an issue to the Github project. ================================================ FILE: docs/_config.yml ================================================ remote_theme: just-the-docs/just-the-docs@v0.10.1 aux_links: "POP Forums on GitHub": - "https://github.com/POPWorldMedia/POPForums" footer_content: "©2025, POP World Media, LLC" title: "POP Forums" ================================================ FILE: docs/architecture.md ================================================ --- layout: default title: Architecture nav_order: 2.5 --- # POP Forums Architecture Forums are a text-driven medium intended to enable communication between people. To that end, the design philosophy behind POP Forums has always been to keep the interface simple, and not fill the screen with interface elements that don't serve that philosophy. Things that you don't need are hidden by default. Conversation comes first. Peripheral concerns include search engine optimisation, performance and maintainability. As an open source project, intended first to be used in community sites like [CoasterBuzz](https://coasterbuzz.com/), its evolution is not perfect, or even ideal, but it does represent two decades of refinement. Some parts are pretty cool, others desperately need to be refactored. No pull request will be ignored! ## Data structure POP Forums was originally written using SQL Server as a data store, but the data access bits are contained entirely in the `PopForums.Sql` project. An enterprising developer could easily port this to any of the technologies supported by the .Net ecosystem (which is to say, all of them). This library also contains a basic caching layer, leveraging in-memory cache. It is seeded by a single SQL script, `PopForums.sql`. This is the basic configuration, and it works fine for light duty use on cheap hardware, virtual or otherwise. To facilitate scaling, caching can be delegated to Redis, using the `PopForums.AzureKit` library. (Instructions are found in the [Using AzureKit Library](azurekitlibrary.md) section.) This employs a two-level cache that combines the use of Redis and local memory. The app will first attempt to retrieve data from local memory, and if it's not available, it will attempt to retrieve it from Redis. If it's not available there, it will fetch from SQL and then cache that data as apporopriate. This also enables the use of multiple nodes by using the message bus in Redis. When the local cache needs to be invalidated on the other nodes, a message is sent to them via the bus. I didn't invent this pattern, but saw it on a Stack Overflow blog post. The basic search functionality is performed by building an enormous table of words, then matching and scoring them for results. The algorithm to index the words is not efficient, but this too works fine for light duty use. Using the `PopForums.ElasticKit` library enables, wait for it, the use of ElasticSearch. No need to invent something, because Elastic does this really well. (Instructions are found in the [Using ElasticKit Library](elastickitlibrary.md) section.) This is a more robust solution that can scale to larger forums, and it can be used in conjunction with the Redis caching layer. There is a bit of what I call "precomputing" of statistics, because it doesn't make sense to be doing aggregate counts via SQL queries. For example, the post count on a topic is incremented or decremented at the time a post is added or (soft) deleted. ## Background Processing A number of different tasks are necessarily performed asynchronously: * Award Calculation * Close Aged Topics * Email * Post Image Cleanup * Search Indexing * Subscribe Notifications * User Session Cleanup The default implementation uses jobs registered as derivatives of `Microsoft.Extensions.Hosting.BackgroundService`. This works fine in a single-node environment, and most of the actions are not resource intensive, save for the search indexing (regardless of using the base search or Elastic). The solution for that is to encapsulate the jobs as Azure Functions. These are fantastic in a production situation because you'll literally spend pennies a month on them, while not impacting the resources of your web nodes. If you run on Azure, this is a no-brainer. ## Image Handling Out of the box, POP Forums stores post images in the database. Streaming those bytes out of SQL Server all the way to the browser is reasonably efficient, but the memory cost is not zero. The advantage of this arrangement is that the forum is very portable, as you can export the database to a `.bacpac` file and restore it, with images, anywhere. User avatars and images are also stored this way. For scale, especially if you pay for your database by the bit, it may be preferable to store post images in Azure Blob Storage. If you're using Azure Functions, you'll already need a storage account for the background queues. This too is configured using the `PopForums.AzureKit` library, saving images to a public blob container. This means that you could also use a CDN, if you're really nuts about performance. Because a user might upload an image but not submit the post, a cleanup job is run to remove images that are not associated with a post. ## Scaling POP Forums The above options allow you to greatly scale the application. Because the app uses ASP.NET's authorization and authentication, running multiple nodes requires a shared data protection key. This is outlined elsewhere and set in the `Program.cs` startup. Using the CoasterBuzz database as a reference, three Azure Web App instances, running on P0v3 Linux machines, backed by a 50 DTU SQL database, can handle 1,000 requests per minute without issue. I haven't tested with higher loads and configurations, but it's not clear that either would be a bottleneck running at higher levels. ## The Web Application All of the web app code is contained in the `PopForums.Mvc` project, which also references the base `PopForums` and `PopForums.Sql` libraries, and the above-mentioned libraries as necessary for scale. The assets, including all of the CSS and transpiled TypeScript, are also shipped in the `Mvc` library. This makes it really easy to update the forum bits in your own application, without having to replace a bunch of loose files. Update the package, and you're done. The app leverages ASP.NET's SignalR for real-time communication with the server via web sockets, including notifications of new posts, updating the forum and topic grids, new private messages, etc. In accordance with the simple design philosophy, the web app does not use any specific front-end library, aside from Vue.js, which is used for the admin interface. Again, the intent is to produce search engine friendly markup without a web of dependencies and npm packages. That doesn't mean that there isn't any rich interactivity, because a number of small, raw elements are written in TypeScript. They live in `PopForums.Mvc/Client`. Along with a few small service classes and a simple state engine, "reactive" elements are updated when a notification comes in via web sockets. While server-side localization is straight forward enough, the client-side bits use a small JSON payload apply the right language to the interface. For example, the use of time words varies by language, so the `FormattedTime.ts` component uses those strings for "5 minutes ago" or whatever the right variant is. ## Unit Testing The unit test suite is rough, because there are parts of the app that have not been refactored from an earlier state. A few of the service classes look like dumping grounds, and many do too many things. That makes writing tests for those retroactively difficult, and likely unnecessary if they'll eventually be refactored anyway. You'll also find code in the controllers that likely doesn't belong there. Again, this is after two decades of change. Like any good project, it will never be "done." ================================================ FILE: docs/azurekitlibrary.md ================================================ --- layout: default title: Using Azure Kit Library nav_order: 6 --- # Using AzureKit Library The `PopForums.AzureKit` library makes it possible to wire up the following scenarios: * Using Redis for caching (not dependent on Azure specifically... Redis runs everywhere!) * Using Azure Storage queues and Functions to queue work for search indexing, emailing and scoring game award calculation * Using Azure Storage for image uploads * Using Azure Search * Using Azure Storage for hosting uploaded images in posts * Using Azure Table Storage for error logging You don't need to use the AzureKit components to run in an Azure App Service. These components are intended for making scale-out possible. The web app can run self-contained in a single node on an Azure App Service without these optional bits. ## Configuration with Azure App Services and Azure Functions The POP Forums configuration system uses the typical configuration files, but adhere's to the overriding system implemented via environment variables. This by extension means that you can set these values in the Application Settings section of the Azure portal for App Services and Functions. It uses the colon notation that you may be familiar with. For example, use `PopForums:Queue:ConnectionString` to correspond to the hierarchy of the `appsettings.json file`. (For Linux-based App Services and Functions in Azure, use a double underscore instead of colons in the portal settings, i.e., `PopForums__Queue__ConnectionString`.) ## Irrelevant settings when using Azure Functions Once you get the background stuff out of the web app context, some of the configuration options in the admin are no longer applicable. * In email: The sending interval and mailer quantity no longer matter, because the functions only respond when there's something in the queue, and scale as necessary. You may need to limit the number of instances via host.json or the Azure portal if your email service provider throttles your email delivery. * In search: The search indexing interval only reacts when something is queued (like email). Furthermore, if you use Azure Search or ElasticSearch, the junk words no longer apply, as these indexing strategies are handled by the appropriate service. * In scoring game: The interval is again irrelevant because of the queue. ## Running locally You can almost run everything in this stack locally. Here's the breakdown: * Redis is easy to run locally using a Docker container: `docker run -p 6379:6379 -d redis` * Azure Storage (for queues) can be simulated locally running [Azurite](https://github.com/azure/azurite) on Windows or Mac (the Azure Storage Emulator has been deprecated). Run this in a Docker container with `docker run -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite` * Azure Functions CLI runs on Windows and Mac. * Azure Search only runs in Azure. * ElasticSearch can run in a Docker container. ## Setting locale in Azure Functions Since Azure Functions do not run as a normal web app, listening to the locale of the user's web browser, it defaults to whatever Azure decides is default, probably `en-US` in a lot of places. For some of the functions that are generating notifications, this matters, because you might be serving a Spanish-speaking audience and want them to get notifications in that language. To set the language in a function, add the following to those function methods in one of the supported languages: ``` Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("es"); ``` ## Using Redis for caching Redis is a great tool for caching data for a stand-alone instance of the app or between many nodes. The default caching provided by the `PopForms.Sql` implementation uses in-memory cache in the app instance itself, which doesn't work when you have many nodes (that is, several web heads running behind a load balancer, like a scaled-out Azure App Service). Redis helps by caching data in a "neutral" location between these nodes. To use Redis (which is available all over the place, and _not_ just in Azure), use the following configuration lines in your ASP.NET `Program.cs`: ``` var builder = WebApplication.CreateBuilder(args); var services = builder.Services; ... services.AddPopForumsRedisCache(); services.AddSignalR().AddRedisBackplaneForPopForums(); ... ``` The first line configures the app to use the Redis caching mechanism. Under the hood, this replaces the `PopForums.Sql` implementation of `ICacheHelper` with the one found in `PopForums.AzureKit`. The second line adds `AddRedisBackplaneForPopForums()` to the configuration of SignalR, so that websocket messages back to the browser are funneled to clients regardless of which web node they're connected to. For example, a user can make a post connected to one node, and the message to update the recent topics page will be signaled to update for users on every node. You'll also need to setup some values in the `appsettings.json` configuration file (or equivalents in your Azure App Service configuration): ``` { "PopForums": { "Cache": { "Seconds": 180, "ConnectionString": "127.0.0.1:6379,abortConnect=false", "ForceLocalOnly": false }, ... ``` * `Seconds`: The number of seconds to persist data in the cache. * `ConnectionString`: The connection string to the Redis instance. * `ForceLocalOnly`: When set to true, the app will not use Redis, just local memory. This might be useful if you scale down to one node and no longer need Redis, but don't want to redeploy, for example. Obviously don't set to true if you're running more than one node. This version of `ICacheHelper` is actually a two-level cache. The data is stored locally in the instance memory of the web app, as well as Redis, because it's still faster if it can avoid calling over the wire. Invalidation of data is handled over Redis' built-in communication channels. So if you update something with a particular cache key, Redis will notify all nodes to invalidate any local value they have. Because it's a two-level cache, you might find that your Redis stats seem not to be active, and when it is being called, it's usually a miss. That's because it doesn't have to go to the Redis instance itself, since the value is already in local memory. The Azure functions do _not_ use caching. The data that is fetched by the functions is typically transient, not cached or not likely to be. If you want to prefix cache keys with a specific string, you can do that by using the dependency injection to replace the default `ITenantService` and implementing its `GetTenant()` method. The default implementation simply returns an empty string. See [Multi-tenant options](multitenant.md) for more information. To run Redis locally, consider using Docker. It only takes a few minutes to setup. Use the Google to figure that out. ## Instrumenting Redis and cache usage Most managed Redis services have ways to generally observe the behavior and health of the service, but you might be interested in going deeper. For example, the default TTL for caching on all of POP Forums is 90 seconds, but that might not be the "right" amount of time. Also, because this implementation is a two-level cache, monitoring Redis alone doesn't give you the complete picture. POP Forums has an interface called `ICacheTelemetry` in this library, with a default interface that is just an event sink. If you use an external monitoring service like Azure Insights, you may want to replace this with your own implementation. It's super easy! The interface only has two members: ``` void Start(); void End(string eventName, string key); ``` The Redis implementation of `CacheHelper` wraps each call to the memory cache and Redis with the above methods. It includes the cache key and the type of event (`SetRedis`, `GetRedisHit`, `GetRedisMiss`, etc.) for you to persist in whatever your monitoring solution is. In the [hosted forums](https://popforums.com/), we use the following to write the events to Azure Insights. The `TelemetryClient` comes in via dependency injection: ``` public class WebCacheTelemetry : ICacheTelemetry { private readonly TelemetryClient _telemetryClient; private Stopwatch _stopwatch; public WebCacheTelemetry(TelemetryClient telemetryClient) { _telemetryClient = telemetryClient; } public void Start() { _stopwatch = new Stopwatch(); _stopwatch.Start(); } public void End(string eventName, string key) { _stopwatch.Stop(); var dependencyTelemetry = new DependencyTelemetry(); dependencyTelemetry.Name = eventName; dependencyTelemetry.Properties.Add("Key", key); dependencyTelemetry.Duration = new TimeSpan(_stopwatch.ElapsedTicks); dependencyTelemetry.Type = "CacheOp"; _telemetryClient.TrackDependency(dependencyTelemetry); } } ``` Then, to wire up this new implementation, we swap out the event sink for our code in `Program.cs`: `services.Replace(ServiceDescriptor.Transient());` ## Using Azure Storage queues and Functions Azure Storage queues can be used instead of using SQL tables. Using SQL for this is not inherently bad, and honestly the volume of queued things in POP Forums probably never gets huge even on a busy forum, but with queues you get some of the magic of triggering Azure Functions, for example. These are most logically used when you have functions. To enable queue usage, use this in your `Program.cs` config: ``` var builder = WebApplication.CreateBuilder(args); var services = builder.Services; ... services.AddPopForumsAzureFunctionsAndQueues(); ... ``` It's important to _not_ have `services.AddPopForumsBackgroundServices();` in your `Program.cs`, because this would run the background services in the context of the web app. You don't want that, because you're going to run them in Azure Functions. You'll also need to add a connection string to your Azure Storage account and web app service base. These values must appear in the configuration of your web app _and_ Azure Functions. ``` { "PopForums": { "WebAppUrlAndArea": "https://somehost/Forums", "Queue": { "ConnectionString": "DefaultEndpointsProtocol=https;AccountName=youraccountname;AccountKey=xxxYourAccountKeyxxx==" ... ``` Look at the Azure documentation to see how to provision and deploy Azure Functions, and apply that new knowledge to deploy the `PopForums.AzureKit.Functions` project. (Defining Azure Functions is beyond the scope of this documentation.) You should avoid committing any connection secrets to configuration in source control. See the section above about configuration, and make sure that your Functions have the same settings as your web app. The `WebAppUrlAndArea` is used to point the functions back at your web app to notify them as necessary and have them in turn notify users in real-time. The URL should end without a slash, and probably ends in `/Forums` unless you changed the name of the area throughout the code. Behind the scenes, the award calculator uses this to call an endpoint on the web app and let it know that a user has received an award. For security, it uses a hash of the queue connection string, _which must be the same for the web app and the functions_. The connection string for using the local Azure storage emulator is `UseDevelopmentStorage=true`. ## Using Azure Search _Note: v18+ breaks compatibility with previous indexes using Azure Search._ Use this in your `Program.cs` configuration if you're using web in-process search indexing: ``` var builder = WebApplication.CreateBuilder(args); var services = builder.Services; ... services.AddPopForumsAzureSearch(); ... ``` Under the hood, this replaces the `PopForums.Sql` implementation of the search interfaces with those used for Azure Search. For use in the Azure functions, you'll need to set the `PopForums:Search:Provider` (or `PopForums__Search__Provider` if it's Linux-based) setting in the portal blade for the functions to `azuresearch`. You'll also need to setup the right configuration values: ``` { "PopForums": { "Search": { "Url": "https://somesearchservice.search.windows.net", "Key": "99011A70D3D50D251B0A6141A97B40E7", "Provider": "" }, ``` * `Url`: The URL for Azure Search, typically `https://{nameOfSearchService}.search.windows.net` with the name set in the Azure portal * `Key`: A key provisioned by the portal to connect to Azure Search * `Provider`: This is only used in `PopForums.AzureKit.Functions`, where it's used to switch between `elasticsearch`, `azuresearch` and the default bits in the `PopForums.Sql` library. _Important: If the value is left blank, the Azure Functions will use the SQL-based search provider._ ## Using Azure storage for hosting uploaded images in posts The default implementation for uploading images into forum posts is to upload them into the database. While this is convenient and super portable, it may not be the least expensive option, since database storage is typically more expensive than other means. To that end, you can use `AzureKit` to upload and host the images in an Azure storage container. There are a few configuration values you'll need: ``` "PopForums": { "BaseImageBlobUrl": "http://127.0.0.1:10000/devstoreaccount1", "Storage": { "ConnectionString": "UseDevelopmentStorage=true" }, ``` * `BaseImageBlobUrl`: The base URL for the storage where images are uploaded. For local development, using the Azurite storage emulator, this is `http://127.0.0.1:10000/devstoreaccount1`. For a typical Azure storage account, it's probably something like `https://mystorageaccount.blob.core.windows.net`. It should *not* end with a slash, and it shouldn't end with the container name, since that's added in the repository code. In the event you have to move the images for some reason, it's ideal if you could alias a domain name that you own to the storage account. * `ConnectionString`: It's assumed that you're going to use the same storage account as your queues, but regardless, you need to specify the connection string here. You need this in the web app *and* the functions app. The code will create a container called `postimage` in your storage account, but if you plan to use the public endpoints of the storage account (instead of a CDN), make sure that `Allow blob public access` is enabled for the account, and then when the container is created, it will be created with the `Blob` access level, meaning anyone can access the images, but they can't see the directory or otherwise manipulate the storage account. Your web app will need to register the right implementation for the `IPostImageRepository`, and this is achieved in your startup/program with this line in the service configuration: ``` using PopForums.AzureKit; ... services.AddPopForumsAzureBlobStorageForPostImages(); ``` It's important to note that `PopForums.AzureKit.Functions` is already wired to use the blob storage `IPostImageRepository` version, because it's assumed that if you're already using an Azure queue, you also have a storage account. Another thing to keep in mind is that if you're working locally, and your Azurite instance doesn't have `https` configured, it will break because most browsers do not allow non-`https` images to appear in a secure page. The simplest work around for this is not to install a local certificate, but to change your launch settings for the web app to not run on `https`. ## Using Azure Table Storage for error logging You may prefer to do your error logging to Azure Table Storage instead of the SQL database. You can do this by adding one line to your `Program` file, which swaps out the SQL error repository for the table storage: ``` services.AddPopForumsTableStorageLogging(); ``` The one limitation here is that you can't use the admin UI to look at errors, since paging and ordering is fairly crude in table storage. For the connection string, it will use whatever is found in `PopForums:Storage:ConnectionString`, the same setting used by the image uploads. ================================================ FILE: docs/customization.md ================================================ --- layout: default title: Customization nav_order: 2.5 --- # Customization POP Forums is fairly easy to customize to make it your own. And please, make it your own, because the default Bootstrap style is pretty boring. ## Style POP Forums uses [Bootstrap](https://getbootstrap.com/) for its base style. We're big fans of the library because it really does provide a solid starting point and mature components that feel like the _lingua franca_ of web user interfaces. It's also super easy to customize it to your liking with relatively little effort. ### The basic layout that hosts the forum The basic page that we include in the main and sample repositories is fairly sparse: ``` @ViewBag.Title @await RenderSectionAsync("HeaderContent", false)
@RenderBody()
``` You'll notice that after the `title` tags, we render a section called `HeaderContent`. This is where the forum drops in all of its `script` and `link` references for Javascript and CSS. If you want to go deeper, look in the `PopForums.Mvc` project at `/Areas/Forums/Views/Shared/PopForumsMaster.csthml`. ### Style overrides One simple approach to customizing the look is to include a style sheet, linked in the template header _after_ Bootstrap. So given the template shown above, you would include your `link` tag to your CSS after the `RenderSectionAsync` call. Mostly, your CSS will consist of things like font selection and colors on high-level elements. For example, given that you've imported fonts elsewhere: ``` body { font-family: 'Roboto Slab', 'Times New Roman', serif; font-size: 16px; } nav, h1, h2, h3, h4, h5, small, .small, .btn, .breadcrumb { font-family: 'Open Sans Condensed', Helvetica, Arial, sans-serif; font-weight: 700; } a, a:visited, a:hover, .btn-link { color: red; text-decoration: underline; } ``` If you wanted to override something specific in Bootstrap, like adding a big border to buttons, you would have to make sure you apply an `!important` designation. ``` .btn { border: solid 2px #000000 !important; } ``` ### Bootstrap replacement You can also use your own Bootstrap build and go crazy with customization on the variables. There are great pre-built themes to download from [Bootswatch](https://bootswatch.com/), and [Bootstrap Build](https://bootstrap.build/) has a hassle-free way to customize everything and output a ready-to-use build of Bootstrap. Of course, you can get the [Bootstrap source](https://github.com/twbs/bootstrap) and modify any of the variables or `SCSS` files and build your own, if you like. To use your own Bootstrap build from any of the methods above, first you have to turn off the rendering of the default Bootstrap included in the `PopForums.Mvc` package. To do so, you'll need to change the `appsettings.json`: ``` { "PopForums": { "RenderBootstrap": false, ... ``` With that taken care of, considering the template above, add references to your built Bootstrap CSS _and_ script _before_ you render the header content: ``` @await RenderSectionAsync("HeaderContent", false) ``` ## Forum adapters The MVC project has an interface called `IForumAdapter`, which allows you to generate your own view model for a topic, typically with new or augmented data. When a forum adapter is configured in the admin of the app (on a per-forum basis), it uses the code in that configured adapter to render a specific view (typically in the `Views/Shared` folder of your app) and the model the adapter specifies. Consider the following: ``` public class TestAdapter : IForumAdapter { public Task AdaptForum(Controller controller, ForumTopicContainer forumTopicContainer) { // not changing anything in the forum (topic list), just set the model as the existing container Model = forumTopicContainer; return Task.CompletedTask; } public async Task AdaptTopic(Controller controller, TopicContainer topicContainer) { // for the topic (thread) view, let's use the existing model, but add something to the `ViewBag` for the view Model = topicContainer; // now get the "extra" var resolver = controller.HttpContext.RequestServices; var scoreService = (IScoreService)resolver.GetService(typeof(IScoreService)); var highScore = scoreService.GetHighScore(); controller.ViewBag.HighScore = highScore; // use Shared/ScoreTopic.cshtml for the view ViewName = "ScoreTopic"; } ... ``` Now a different view will be rendered, and it might look something like this: ``` @model PopForums.Models.TopicContainer

High score: @ViewBag.HighScore

@foreach(var post in Model.Posts) { // render posts from the model ``` This is super flexible, but not without a lot of work. You're highjacking the model from a page like `/Forums/Topic/some-topic`, replacing it with your own, then rendering your own view. To light one of these up, you'll have to go to the admin -> Forums -> Edit, and in the bottom field, labeled `Forum Adapter (optional, use "Namespace.Type, AssemblyName")`, you should enter what it's asking for. So if the fully qualified name of your adapter is `MyLibrary.MyForumAdapter` in the `MyLibrary` assembly, you would put `MyLibrary.MyForumAdapter, MyLibrary` here. ================================================ FILE: docs/elastickitlibrary.md ================================================ --- layout: default title: Using ElasticKit Library nav_order: 7 --- # Using ElasticKit Library The `PopForums.ElasticKit` library makes it possible to wire up the following scenarios: * Use ElasticSearch for search instead of the built-in search indexing. _Important: The client library referenced in v15.x is designed to work against v6.x of ElasticSearch, while v16.x,v17.x and v18.x, v19.x uses v7.x of ElasticSearch. v20.x and v21.x uses v8.x of ElasticSearch. v22.x uses v9.x of ElasticSearch. For prior versions, please see the `docs` folder in those branches. Configuration is significantly different for >=v22.1, and is a breaking change._ ElasticSearch can run quite literally anywhere in a docker container or straight up in a VM, if that's your thing. Also keep in mind that the implementation that AWS uses is actually a fork, so there are some differences about how the managed service is, uh, managed. In the commercial hosted version of POP Forums, we use Elastic's managed service running in Azure. Elastic runs in _all_ of the major clouds and is generally reasonably priced. ## Configuration with Azure App Services and Azure Functions The POP Forums configuration system uses the `appsettings.json` file, but adhere's to the overriding system implemented via environment variables. This by extension means that you can set these values in the Application Settings section of the Azure portal for App Services and Functions. It uses the colon notation that you may be familiar with. For example, use `PopForums:Queue:ConnectionString` to correspond to the hierarchy of the `appsettings.json` file. (For Linux-based App Services and Functions in Azure, use a double underscore instead of colons in the portal settings, i.e., `PopForums__Queue__ConnectionString`.) ## Irrelevant admin settings when using ElasticKit * In search: The search indexing interval only reacts when something is queued for in-Web processing, not Azure Functions. Furthermore, if you use ElasticSearch, the junk words no longer apply, as these indexing strategies are handled by ES. ## Using ElasticSearch for search ElasticSearch is a search engine you can run on your own or in managed services from AWS, Elastic and others. To use this service instead of the internal POP Forums search indexing, you'll need to configure this line in your `Program.cs` if you're using web in-process search processing: ``` using PopForums.ElasticKit; ... namespace YourWebApp; ... services.AddPopForumsElasticSearch(); ``` For use in the Azure functions, you'll need to set the `PopForums:Search:Provider` (or `PopForums__Search__Provider` on a Linux instance) setting in the portal blade for the functions to `elasticsearch` (see `Provider` config below). You'll also need to setup the right configuration values if you're running web in-process: ``` { "PopForums": { "Search": { "Url": "https://myelasticsearchindex", "Key": "", "Provider": "" }, ``` * `Url`: The base URL for the ElasticSearch endpoints. If you're using managed ES from Elastic, this is the "ElasticSearch Copy endpoint" result in the portal. * `Key`: This is the API key. * `Provider`: This is optional in the web app and not actually implemented anywhere other than in our Azure Functions example project, where it's used to switch between `elasticsearch`, `azuresearch` and the default bits in the `PopForums.Sql` library. Configuring ElasticSearch and setting up security rules for it are beyond the scope of this wiki. ================================================ FILE: docs/externalloginconfig.md ================================================ --- layout: default title: External Login Configuration nav_order: 5 --- # External Login Configuration >Important: External logins are not the same as OAuth-Only Mode. Sometimes referred to as "social logins," these are simply a shortcut so your users don't need to remember their forum-specific credentials. They still create an account in the forum. [OAuth-Only Mode](oauthonly.md) relies entirely on an external identity provider and provisions accounts through it. > > External logins are great for public forums. For corporate or private forums coupled exclusively to an external identity platform, use OAuth-Only Mode. Starting in v16, POP Forums is completely decoupled from the Identity libraries that verify user identity via third party services, including Google, Facebook and Microsoft. We already don't use Identity because it's so tightly coupled to Entity Framework, with strong opinions about how to store user data. Identity also requires that you configure it at app start (or restart if you change it), and it can't be changed at request time. That prevents a multi-tenancy scenario from working. It was time to cut the cord. We spun off the [PopIdentity](https://github.com/POPWorldMedia/POPIdentity) project to be a lightweight, non-opinionated means to do the necessary round trips to identity providers and just give you the data that you want, mostly the ID, name and email of the user. It does not bake the identity into a `Principal` for general use. Check out the sample project there for more information. In POP Forums, you can go to the External Logins page of the admin area and configure Google, Facebook, Microsoft and any generic OAuth2 provider that returns JWT's. Check the box, fill in the client ID and secret from the providers. For each, you'll need to specify the callback URL. These are configured: * In Facebook's developer administration, under the "Facebook Login" and "Products" navigation at left. * In the Google Cloud Console, drill down to "Credentials" under "API's and Services." * In Microsoft's Azure portal, search for "Azure Active Directory," choose "App Registrations," choose or create your app, then under "Authentication" enter your redirect URL. The format for the URL is this, substituting in your domain: `https://whateveryourdomainis/Forums/Identity/CallbackHandler` WARNING: That URL might be case sensitive in some services. Use the caps in "Forums" and such, because that's how the app will generate the URL. Once configured, you'll see buttons on the login page for each service you've enabled. There's also one for any other OAuth2 provider that returns JWT's. For that option, you'll need to fill in the URL's for the base login URL (POP Forums will append the appropriate query string) and token fetching URL (again, we'll handle the query string). ================================================ FILE: docs/faq.md ================================================ --- layout: default title: FAQ nav_order: 3 --- # Frequently Asked Questions These are a few of the questions people ask me about the project. Feel free to ask other questions in the [GitHub discussions](https://github.com/POPWorldMedia/POPForums/discussions). If you're thinking it, you're probably not the only one! If you find a defect or want to request a feature, use the issue tracker on GitHub for that, please. ## Do I have to pay for this or not? Not. POP Forums is an open source software project hosted on GitHub for use under the MIT license. There is a commercially hosted version available at [PopForums.com](https://popforums.com/), yes, for people who don't write code or don't want to mess with managing their own software. Everything on GitHub continues to be open source. ## Another forum app? For real? Yeah, I know. I'd like to think that this one is a little different, because it doesn't exist to fit some generalized needs, it exists to fit the needs of real communities, like [CoasterBuzz](https://coasterbuzz.com/). The design goal of the app, from its early days in 1999, has always been to design for users, and not be a science project. This app lives because it has been required for sites like CoasterBuzz for more than two decades, and it will continue to evolve because those sites will evolve. It just makes sense to share it with others. ## Sounds like you've been doing this a long time. Yes, I sometimes feel cursed to rewrite it for all eternity. The Webforms versions were really kind of a mess, and no version was a true rewrite. Once MVC came along, it gave me great incentive to start fresh. Dotnet Core and the evolving front-end frameworks give plenty of new opportunities for refactoring. ## Is this project the basis for the commercial hosted product? Yes, it's the very same code, though obviously decorated with additional code to facilitate multi-tenancy and provisioning. ## What languages are supported? Currently we have English, Spanish (es), Dutch (nl), Ukrainian (uk), Taiwanese Mandarin (zh-TW) and German (de). If you'd like to translate, the .resx file has around 400 entries. Open an issue to learn more, and we can talk about a pull request to add another language. ## You used to work on the forums for MSDN and TechNet. Is this that forum? Not at all. That app served a great many different functions and was integrated with Microsoft ID's, a centralized profiling system, etc. It was/is huge. This app has its roots in the web sites I've been running for fun and profit for years, to the extent that you can find old posts on those sites from the turn of the century with all kinds of formatting failures. Those were the ASP.old days. ## I noticed you're not using [some ORM framework]. Why not? One of the requirements back in the day was to simply work with the existing data structures of v8.x, a Webforms app. In that sense, the data plumbing was already pretty well established and known to work, and it has followed all the way up through the Core version. My opinion is that ORM's tend to be leaky abstractions that never work in the black box way that you would hope. I have adopted Dapper though, which covers the core use case that you're really after anyway: Mapping parameters to queries and results to objects. One doesn't have to write actual SQL all that often, so using an ORM doesn't provide a ton of value. ## You don't name your async methods with the `Async` suffix. Just who do you think you are? Look, when almost all of your methods are async with no synchronous version what's the point? The only place I use it is when there are both synchronous and asynchronous methods. Your fancy IDE knows what the return type is, and the compiler lets you know when you're not awaiting. You'll be fine. ## What external frameworks are you using, and why? I wanted to keep external binaries to a minimum, but I'm using MailKit for email functions, ImageSharp for photo resizing, NSubstitute for test mocking, and xUnit for unit testing. On the front end, the main app uses vanilla web components written in TypeScript, along with Bootstrap and TinyMCE. The admin area uses Vue.js. Github has that handy dependency graph now that you can look at for more information. ## What? You're not using React? Here's the thing about a forum... it's mostly walls of text. I can tell you from the 60,000+ topics I have indexed on a couple of sites that it's super SEO friendly. To that end, the functionality of a forum is mostly making posts, which doesn't require a big library to do. That's why there are little web components spread around on little islands, and not an all-in effort to React. Heck, the admin area uses Vue.js, but even that works by way of a simple script reference, and no transpiling or bundling. ## The unit tests suck. That's not a question. In porting to Core, much of the controller-level unit testing didn't come along, and it needs a lot of refactoring. Ideally, there shouldn't be so much logic in the controllers, but there is still some there. ## What's the release roadmap? It has generally been my intention to keep up with the latest .NET framework versions, which are now reliably annual and released later in the year. You can check the issue tracker for stuff currently in flight. While v19 had a lot of big bang features with a large blast radius, that seems less likely going forward. ## Can I contribute? I very much welcome translations of the `.resx` files, so send a pull request for those immediately! If someone really digs into the source code and understands it in a non-trivial way, then yes, I'll happily accept pull requests. If you can find a bug to squish from the issue log, that would be a great PR to see! ================================================ FILE: docs/features.md ================================================ --- layout: default title: Features nav_order: 1.5 --- # Features ## Classic forum functionality * Categories, forums, topics, oh my! * Optional Q&A-style threading * Direct message, real-time chat between users * Upload photos or embed external images * Automatically embed YouTube videos * Selectively quote previous posts * Real-time notifications in-app * Rich text editing * Avatars and signatures, mutable by users ("hide vanity") * Recent topics across all forums * User profiles with links to social networks * Save your favorite topics, subscribe automatically to new post notifications * Mark individual or all forums read * Jump to the newest post * Real-time updating with new posts * Continuous scroll topics * Vote up and recognize posts * Automatic adjustments to display local times * Private forums * Restrict posts to certain roles by forum * Edit posts * Ignore users * Localized for English, Spanish, German, Dutch, Ukranian and Taiwanese Mandarin * Fast page rendering, average 20ms on Azure App Service P0v3 and SQL elastic pool at 50 eDTUs and 900k posts. ## Administration * Your own terms of service * Adjust number of topics and posts per page * Restrict size of uploaded images and YouTube video size * Automatically close topics after days of inactivity * External (social) logins * External identity (All-OAuth mode) that relies on your OAuth/OIDC provider, ideal for enterprise. * View detailed security logs * Limit posts by time interval in seconds * View all recent user sign-ups * Error logging and viewing * Monitor last run of background services * Edit users * Email all users * Set topic/post page size ## Moderation * All-private, sign-up required option * E-mail confirmed sign-ups * CAPTCHA check * Parse out naughty words * Assign users to custom roles, limit viewing and posting to those roles by forum * Approve profile photos * Ban e-mail and IP addresses * Edit and soft delete posts, with history * Close, pin, move, soft or hard delete topics * View moderation logs ================================================ FILE: docs/index.md ================================================ --- layout: default title: Home nav_order: 1 --- ![POP Forums logo](https://avatars2.githubusercontent.com/u/8217691?s=200&v=4) POP Forums v22 is a forum app for ASP.NET (formerly known as Core), used as the base for several sites maintained by the author, as well as a commercial, cloud hosted product. It's a long-term commitment to great community. If you're looking for the commercial hosted product and support for it, go to [popforums.com](https://popforums.com/). >_This documentation is for the open source POP Forums project. For documentation about the commercial hosted product, visit [support.popforums.com](https://support.popforums.com/). If you're working with a version that isn't v22, check the `/docs` folder in the source branch that matches the version you're using._ Get a load of [all the features](features.md). Make test posts and try it out here: https://meta.popforums.com/Forums The project goals include: * Use ASP.NET Core and cloud resources for robust scale-out. * Keep the project open source. * Be the best and fastest ASP.NET Core-based forum. * Not duplicate UBB's 1998 UI for the Nth time. * Localize: Now available in English, Spanish, German, Ukrainian, Dutch and Taiwanese Mandarin. More information: * [FAQ](faq.md) * [Architecture](architecture.md) * [POP Forums Version History](versionhistory.md) * [CI build from `main` in action](https://popforumsdev.azurewebsites.net/Forums) * Follow more on [Jeff's blog](https://jeffputz.com/) (may contain autism advocacy and politics) Setup: To set it up, check the installation instructions in the [Start Here](starthere.md) section. POP Forums v13 for ASP.NET MVC 5 is also available as a previous release. Do you speak English and another language? We want to make POP Forums globally useful. Since v9.2, the app is easily localized. Volunteers have translated to Spanish, Dutch, Ukrainian, Taiwanese Mandarin and German, and we'd love help for additional languages. Drop Jeff an e-mail to jeff@popw.com for more information. Found a bug? Add it to the issue tracker. ================================================ FILE: docs/multitenant.md ================================================ --- layout: default title: Multi-tenant options nav_order: 8 --- # Multi-tenant options POP Forums has some plumbing for multi-tenancy, originally created to facilitate the [cloud-hosted version of POP Forums](https://popforums.com/). However, there are some tricks here that you can rely on for shared resource scenarios. # Using `ITenantService` The core library defines `ITenantService` and provides a basic implementation. It has two methods, `SetTenant(string tenantID)` and `GetTenant()`. The former throws a `NotImplementedException` and is not called by any of the code in this repository. It's there for you to use in a true multi-tenant environment. The latter is used all over the place, and the default implementation returns an empty string. # Example: Sharing an instance of ElasticSearch Let's say that we have three different sites running their own copy of POP Forums, each with their own database, in a shared pool in Azure. Having multiple databases doesn't cost you anything extra in this scenario, but if you're using managed ElasticSearch hosted by Elastic (also in Azure), you may want to share a single instance. Like many of the resources in POP Forums, the code in [`PopForums.ElasticKit`](elastickitlibrary.md) makes sure to store and query data with a `TenantID`. By default, this doesn't matter, because the ID is just a blank string. To share this resource, create an implementation for each of your apps. You'll implement just the `GetTenant()` method: ``` public class TenantService : ITenantService { public void SetTenant(string tenantID) { throw new System.NotImplementedException(); } public string GetTenant() { return "mytenantid"; // unique for every app } } ``` Then, in your `Program.cs` file, swap out the default implementation for your own. If you're using Azure Functions with [`PopForums.AzureKit`](azurekitlibrary.md), be sure to do it in the function project's `Program.cs` as well: ``` services.Replace(ServiceDescriptor.Transient()); ``` In our ElasticSearch scenario, the indexer will store the ID with every document, and searches will filter by it. # What uses `TenantID`? It's a long list that is best explored in the source code by finding usages of the `GetTenant()` method, but here's a non-exhaustive list: Core libraries: * All of the queue messaging. That means you'll find a TenantID in the code that dequeues the messages (primarily Azure Functions). * SignalR plumbing, so clients listening for notifications, for example, are unique to the tenant. In `PopForums.AzureKit`: * `IPostImageRepository` (used in naming blobs for image storage) * All of the Redis bits, so you can share a Redis instance. In `PopForums.ElasticKit`: * All of the ElasticSearch bits, see example above. ================================================ FILE: docs/oauthonly.md ================================================ --- layout: default title: OAuth-Only Mode nav_order: 2.7 --- # OAuth-Only Mode >Important: OAuth-Only Mode relies entirely on an external identity provider and provisions accounts through it. [External logins](externalloginconfig.md) are not the same as OAuth-Only Mode. Those are simply a shortcut so your users don't need to remember their forum-specific credentials. They still create an account in the forum. > > External logins are great for public forums. For corporate or private forums coupled exlcusively to an external identity platform, use OAuth-Only Mode. Starting in v20, POP Forums has an OAuth-only mode, which means that user authenticaton is handled entirely by a third party. Examples include OAuth providers of corporate identity systems, like Azure Active Directory, Keycloak, Okta and Auth0. In this mode, users can't create an account in the forum, they can only come in via the external identity provider. The assignment of moderator and admin roles are mapped from claims issued by the identity provider. > This mode is set at the configuration level (`appsettings.json` locally, or the typical environment variables in regular environments). There are consequences for changing this setting to `true` in an established instance of the forum. Existing users would not be mapped to identities from the external provider. Going the other direction would be possible, though each user would need to reset their password with the email address used by the identity system. If you don't have a basic understanding of how OAuth works, now's a good time to do a little research. Here's how this mode works in the forum: * The user can only access a page with a single login button. * Clicking that button sends the user to the external identity provider. * The user enters credentials with the provider if they aren't already logged in. You've likely done this before with "social" logins like Google or Facebook. * The identity provider redirects the user back to the forum, with a JWT token. * The forum calls the identity provider's token endpoint with the provided token and verifies its authenticity. * In return, the identity provider returns information about the user called _claims_. * The forum checks to see if there's a user account associated with the unique identifier from the provider (given in the `sub` claim). If there is no account, it's created with the provided name and email, otherwise it uses the existing one. * The claims are compared to those configured in the forum for moderators and admins, and those roles are assigned to the user if they match. * The forum uses an algorithm to reconcile the name and email of the user. * The user is then logged in and browsing the forum. * After a configured amount of time, the forum will use the refresh token issued by the provider to make sure the user is still legitimate, without the user having to authenticate again. This mode uses OpenID Connect (OIDC) claims. The identity provider, in addition to the `sub` claim, must also return a `name` and `email` claim. The provider might need to be configured for this, but those claims should be present if it implements OIDC. We do have to ask for these claims by specifying `scope` in our request, which we'll get to in a minute. ## Configuring your OAuth Provider The amount of access and configuration that you have in your identity provider varies a ton. At the very least, the provider should have OIDC enabled, and return `email` and `name` claims. Beyond that, it should return specific claims for forum admins and moderators. The forum can assign these roles based on just the presence of a claim, or by the claim and a specific value. >In Azure Active Directory, for example, you can create a group and assign members to it. In an app registration, you can configure tokens to return groups as claims. The claims will all be named `roles`, with values that are guids that identify the groups' object ID's. So if a group called "Forum Admins" has an object ID of `978efeac-3baf-4e61-a519-9b06eb26a0bf`, the token will have a claim called `roles` with that guid as a value. With other identity providers, it may be possible to simply assign a claim called `ForumAdmin` with no value to represent a forum administrator. Find out what _scopes_ are required to make sure you're getting the `name` and `email` claims, as well as those that identify your moderators and admins. The typical scope you'll specify, as it relates to the OIDC standard, is: ``` openid email profile offline_access ``` The last one, `offline_access`, is typically required to generate a refresh token, used as described in the flow above. Finally, the provider has to know what the valid redirect URL back to the forum is. How you set this varies by identity provider. This follows this pattern: ``` https://localhost:5091/Forums/Identity/CallbackHandler ``` This is what you would use to redirect to a locally running developer instance of the forum. For real environments, you replace `localhost:5091` with your domain, like `example.com`. Most providers require `https`. ## Configure POP Forums for OAuth-Only Now that you understand the provider's needs, you can set up the configuration for the forum. These are in addition to the settings described on the [Start Here](starthere.md) page. ``` { "PopForums": { "OAuthOnly": { "IsOAuthOnly": true, "OAuthClientID": "provided by identity provider", "OAuthClientSecret": "provided by identity provider", "OAuthLoginBaseUrl": "where the forum redirects users", "OAuthTokenUrl": "where the forum validates tokens", "OAuthAdminClaimType": "name of the Admin role claim", "OAuthAdminClaimValue": "optional, value of the Admin role claim", "OAuthModeratorClaimType": "name of the Moderator role claim", "OAuthModeratorClaimValue": "optional, value of Moderator role claim", "OAuthScopes": "openid email profile offline_access", "OAuthRefreshExpirationMinutes": "60" } } } ``` Here's what these do: * `IsOAuthOnly`: The master switch for this mode. When set to true, all of the things the forum would do to manage user accounts, like account creation, passwords, email, goes away, delegating it to the OAuth identity provider. * `OAuthClientID`: Sometimes called an "application ID," this value comes from the identity provider to understand what service (your forum, in this case) is talking to it. * `OAuthClientSecret`: The forum uses this value when it calls back to the provider to validate or refresh a token. * `OAuthLoginBaseUrl`: This is the base URL that the forum uses to redirect users to the identity provider. It appends this with a query string, including the callback URL (handled by the forum), the scope, and a state value. * `OAuthTokenUrl`: The URL that the forum uses to validate a code or refresh token that came back from the user redirect. * `OAuthAdminClaimType`: This is the name of the claim that identifies a user as a forum administrator. * `OAuthAdminClaimValue`: This is the value of the above named claim that identifies a user as a forum administrator. If not set, the presence of a `OAuthAdminClaimType` claim with any or no value designates the user as a forum administrator. * `OAuthModeratorClaimType`: This is the name of the claim that identifies a user as a forum moderator. * `OAuthModeratorClaimValue`: This is the value of the above named claim that identifies a user as a forum moderator. If not set, the presence of a `OAuthModeratorClaimType` claim with any or no value designates the user as a forum administrator. * `OAuthScopes`: The scopes to get from the identity provider so that it returns the `sub`, `name` and `email` claims. This is often how you tell the service to return a refresh token as well. Typically, this setting will use `openid email profile offline_access`. * `OAuthRefreshExpirationMinutes`: The number of minutes that should pass until the forum asks the identity provider's token endpoint for an updated refresh token. If the user is no longer valid, they will be logged out on their next request. Use a value that is short enough to cause revoked accounts to be shut out, but long enough that every forum request isn't slowed by fetching a refresh token. ## Troubleshooting Errors should appear right in the user interface. If you need additional context, check the `pf_SecurityLog` table in the database. ================================================ FILE: docs/scoringgame.md ================================================ --- layout: default title: The Scoring Game nav_order: 9 --- # The Scoring Game Let me tell you a story of HR-discouraged workplace fun. Back in the day, prior to the crash-and-burn of Insurance.com, we had this thing in the development part of the company called the Scoring Game. [I wrote about it](https://jeffputz.com/blog/the-scoring-game) a couple of years ago on my personal blog. The long and short of it is that we kept a running total of +/-1’s for virtually anything you can think of, for each participant. This was back in 2006, before it became trendy to do it for everything else on the Internets. Later, Digg started doing all kinds of voting, and it was really the first active example that I can think of that I used in terms of measuring value of content (yes, slashdot did it, but I never went there). Various forums started doing it. StackOverflow based much of its value on a scoring system, along with achievements. When I worked at Microsoft, I worked on the reputation system that feeds the various MSDN properties. It seems inevitable that I’d have to add something like this to POP Forums. Originally, I was thinking just in terms of voting up posts, but then I realized that there were actually two things to build. The voting mechanism was one part, but the actual scoring was a second part that should be decoupled from the voting. So the workflow goes like this: Process Event –> Publish to user profile (optional) –> Get associated awards –> Qualify awards –> Give award To use the system, you only need only a few lines of code. Use the dependency injection to get the implementation of `PopForums.ScoringGame.IEventPublisher`. If you're using the typical constructor injection, you'll probably have a reference to it in your class called `_eventPublisher`. The user can be obtained from an instance of `PopForums.Mvc.Areas.Forums.Services.IUserRetrievalShim` in the controller layer. ```c# _eventPublisher.ProcessEvent("message for feed", user, "TestEventID", false); ``` Pretty simple, eh? The first string is the text that will be published to the user’s feed (if the event is set to publish), the second is the `PopForums.Models.User` object to associate with the event, and the third is the actual event ID. Event definitions are really simple. There are three events that are static, permanently built into the system. These are wired into the post voting, and the creation of new topics and posts. So for example, when someone votes up a post, a string of HTML is passed in to the ProcessEvent() method, with the user object associated with the post, and the event ID PostVote. Events don’t have to be published to the user’s profile, and they don’t even need to assign points. New posts and topic events fall into this category. So what’s the point then? Awards! POP Forums leaves that up to you. Award definitions are super simple as well. We can assign any combination of events to the award. That’s really all there is to it. You can set up stuff anywhere in your app to record events, and publish them to the user profile. Give points, give awards. Knock yourself out! ================================================ FILE: docs/starthere.md ================================================ --- layout: default title: Start Here nav_order: 2 --- # Start Here POP Forums attempts to not get in the way of your application, by working as an MVC area. All of the front-end dependencies are embedded in the Nuget packages, so there's no need to npm packages and build and copy stuff. How to use [The Scoring Game](scoringgame.md) in your own application. ## Upgrading? This version has data changes. From v21.x, run the `PopForums21to22.sql` script included in the `PopForums.Sql` project. If you need to upgrade from v16.x to v20.x, _first_ run the `PopForums16to20.sql` script against your database, which is found in the `PopForums.Sql` project. It's safe to run this script more than once. IMPORTANT: Going from v18 forward, because of the changes to private messages, you must first also delete all of the existing history by running `DELETE FROM pf_PrivateMessage` against your database. The reason that this isn't included in the upgrade script is because you should know it's necessary and do it on your own. Updating your app from the legacy ASP.NET MVC world to ASP.NET Core is non-trivial, and well beyond the scope of this documentation. ## Prerequisites You'll need the following locally: * Visual Studio 2026 or later, with the Azure workload (community version is fine), Visual Studio for Mac or Jetbrains Rider * Node.js (comes with npm) * SQL Server Developer, or SQL Server running in a Docker container * A mail sending service that supports SMTP * Optionally, Docker if you intend to run Azurite, Redis, ElasticSearch, etc. (instructions below) ## Build vs. reference You should definitely get to know the installation information below to understand the project structure, but understand that you can also use POP Forums by way of Nuget package references. The [POPWorldMedia/POPForums.Sample](https://github.com/POPWorldMedia/POPForums.Sample) project shows how you can do this without having to build this project, and it's literally adding a Nuget package reference and adding some config stuff in `Program.cs`. ## Reference * Again, [POPWorldMedia/POPForums.Sample](https://github.com/POPWorldMedia/POPForums.Sample) is a good starting point when using POP Forums via reference, but the `PopForums.Web` project in the source code works similarly, with project references instead of NuGet references. * Reference `PopForums.Mvc` and `PopForums.Sql` from Nuget. If you want to use the scale-out kits (`PopForums.AzureKit` and `PopForums.ElasticKit`), add those as well. * Starting with v19, `PopForums.Mvc` includes all of the front-end goodies, the Javascript and CSS, right in the package. * You'll need a layout view for the forum to live in. * Set up the various options in `Program.cs` as described in its comments and this documentation. * `appsettings.json` will have your forum configuration. * There is no package for the Azure Functions, because it's currently hard to make them work from a shared library in certain situations. However, you can deploy the project from the main repo with ease given the tooling in VS or Azure DevOps Pipelines. Just be sure to set the right values up in the application configuration in the Azure portal. * POP Forums uses ASP.NET Data Protection in multi-node or external login scenarios. Actually, the basic anti-forgery code baked into the framework does as well, so when you deploy, or swap deployment slots in Azure, you need to persist the underlying key somewhere. This is also true if you run multiple nodes (scale out). You can persist the underlying keys in a number of different ways (I prefer Azure Blob Storage). In your `Program.cs`, use `services.AddDataProtection()` and the appropriate extension method. If you don't do this for multi-node, things like social logins and anti-forgery will fail and fill your error logs with stuff about broken things. If you use slots in Azure App Services, you'll also want the Data Protection setup, otherwise the swap will cause everyone to be logged out. For the bleeding edge, latest build from `main`, the CI build packages can be obtained by a MyGet feed: * https://www.myget.org/F/popforums/api/v3/index.json (Nuget package includes the server application and front-end assets) ## Build * Clone the latest source code from GitHub, or use the production packages as described above. Build it. If your IDE doesn't automatically build Javascript or Typescript, be sure to `npm install` in the `PopForums.Mvc` project, and then run the `gulpfile.js`. * The project files require an up-to-date version of Visual Studio 2026 or later, but it also works great with Jetbrains' Rider on Mac or Windows. I prefer it. * This project is built on ASP.NET v10. Make sure you have the required SDK installed (v10.0.100). * The `PopForums.Web` project is the template to use to include the forum in your app. It references `PopForums.Mvc`, which contains all of the web app-specific code, including script and CSS. `PopForums.Sql` concerns itself only with data, while `PopForums` works entirely with business logic and defines interfaces used in the upstream projects. `PopForums.AzureKit` contains a number of items to facilitate using various Azure services. `PopForums.ElasticKit` contains an ElasticSearch implementation. `PopForums.AzureKit.Functions` is an implementation of functions, used if you're not using in-app context background services (see below). * The `main` branch is using Azure Functions by default to run background processes. Run the [Azurite](https://github.com/azure/azurite) container in Docker (works on Windows and Mac). If not, you can run the background things in-process by uncommenting `services.AddPopForumsBackgroundServices()` in `Program.cs` and commenting out or removing `services.AddPopForumsAzureFunctionsAndQueues()`. This causes all of the background things to run in the context of the web app itself. > Running the background services in the web context can cause some wild variations in CPU and RAM usage on a busy forum, especially in the code associated with updating the search index. If you are running in Azure, using Functions is a much better choice for consistent and predictable app performance. ## Installation * Once you've completed one of the above scenarios, reference or build, it's time to fire it up, starting with the configuration file. * `appsettings.json`, in the root of the web project, is the basic configuration file for POP Forums. It works like any other config file in ASP.NET Core, so when you're running in Azure, you can use the colon notation in the App Service application settings to set these values (i.e., `PopForums:Cache:Seconds` as the key). > If you run the app in a Linux App Service or container, your settings notation should replace `:` with a double underscore, `__`. So the above would be `PopForums__Cache__Seconds`. ```js { "PopForums": { "IpLookupUrlFormat": "https://whatismyipaddress.com/ip/{0}", // used on Recent Users screen of admin to lookup IP addresses "BaseImageBlobUrl": "http://127.0.0.1:10000/devstoreaccount1", // if using AzureKit to host images, points to the base URL of images uploaded to blob storage (you should really alias the storage to a domain you own) "Storage": { "ConnectionString": "UseDevelopmentStorage=true" // if using AzureKit to host images, typically the same as the Queue:ConnectionString, but the place where images are uploaded to blob storage }, "Database": { "ConnectionString": "server=localhost;Database=popforums21;Trusted_Connection=True;TrustServerCertificate=True;" }, "Cache": { "Seconds": 180, "ConnectionString": "127.0.0.1:6379,abortConnect=false", // used for Redis cache in AzureKit "ForceLocalOnly": false // used for Redis cache in AzureKit }, "Search": { // used for Elastic or Azure Search (see docs) "Url": "popforumsdev", "Key": "99011A70D3D50D251B0A6141A97B40E7", "Provider": "" }, "Queue": { // used for queues with Azure Functions "ConnectionString": "UseDevelopmentStorage=true" }, "LogTopicViews": true, // optional, records topic views for future analytics "ReCaptcha": { // Google ReCaptcha on signup (the key/secret below works on localhost) "UseReCaptcha": true, "SiteKey": "6Lc2drIUAAAAAPaa1iHozzu0Zt9rjCYHhjk4Jvtr", "SecretKey": "6Lc2drIUAAAAADXBXpTjMp67L-T5HdLe7OoKlLrG" }, "WebAppUrlAndArea": "https://somehost/forums", // used only by Azure Functions to find endpoint of your web app "RenderBootstrap": true, // optional, defaults to true, put false here if your host page will have its own build of Bootstrap CSS "OAuthOnly": { // this section is detailed in the OAuth-Only Mode section } } } ``` * Attempt to run the app locally via Kestrel, and go to the URL `/Forums` to see an error page about not finding the settings table. It will fail either because the database isn’t set up, or because it can’t connect to it. The biggest reason for failure is an incorrect connection string. If you change nothing locally, by default it's looking for a local database on the default SQL Server instance called `popforums21`. * If you want to use the setup page (and you should), don’t run the SQL script. Once the POP Forums tables exist in the database, the setup page will tell you that you’re prohibited from going there. * Point the browser to `/Forums/Setup` now, and if your connection string is correct, you should see a page with some of the basic fields to set up. > If you're running in OAuth-Only Mode, there is no setup for the fields below. The forum will attempt to set up the database, and that's it. That mode has no email functionality, and user creation and roles are delegated to the external identity provider. See [OAuth-Only Mode](oauthonly.md) for more information. * The `PopForums.Mvc` package includes Bootstrap, which is used as the base style for the entire app. To give it your own look, you can add your own CSS to override Bootstrap in your `_Layout.cshtml`, or do your own build of Bootstrap with whatever variables you like. If you prefer your own build, make sure _both_ the Javascript and CSS tags appear _before_ the `RenderSection` in your header, and set the `RenderBootstrap` setting in `appconfig.json` to `false`. Learn more in [customization](customization.md). * If you're using Azure functions in the background, instead of embedding the background work in the web app (see [Using AzureKit](azurekitlibrary.md)), you'll want to run multiple startup projects, specifically the `PopForums.Web` and `PopForums.AzureKit.Functions`. Here’s what each field on the setup page does: * **Forum title:** This is what your forum will be called at the root, in an h1 tag. You can edit this (and everything else) later. * **SMTP Server:** The host name of the server you’ll connect to for sending e-mail. Enabling this functionality on your server is beyond the scope of this document, but we usually use SendGrid to send email. * **Port:** Typically 25, though some services (like Gmail) use others. * **From e-mail address:** When a user receives e-mail from the forum, it will be “from” this address. * **Use SSL:** Check if your server uses or requires SSL. * **Use ESMTP for credentials:** Check this box if you have to authenticate with your server (this is almost always the case). Checking this makes the two boxes below it editable. * **SMTP User:** User name (often the e-mail address) to authenticate with. Not editable unless the “Use ESMTP” box is checked. * **SMTP Password:** Password to authenticate with. Not editable unless the “Use ESMTP” box is checked. * **Display name:** How you want your name to appear in the forum. * **E-mail:** The e-mail address you’ll use to login with. * **Password:** The password you’ll use to login with. You're almost there! * If you typed everything you need correctly, you should see a happy result, otherwise you’ll see a stack trace and exception. * Restart the app. * From here, you can follow the link to the admin home page and add categories and forums. You’ll be logged in as the user you created, and that account will be part of the Admin and Moderator roles. * Once you’ve added some forums on the “Forums” admin page, you can go to `/Forums` to start posting. * If you want to test your e-mail setup, go to `/Forums/Account/Forgot` and enter your e-mail address. Failures are also logged in the error log, which is found in the admin area. * For future reference, you can revisit the admin area at `/Forums/Admin`, and when you're logged in as an admin, a link appears in the user dropdown from the navigation menu. ## Integration The `PopForums.Web` project is the template you can use as the basis for your own POP Forums apps. If you want to build via the most recent stable builds, the [POPWorldMedia/POPForums.Sample](https://github.com/POPWorldMedia/POPForums.Sample) project is an example of how to do that (see above). The app uses the standard claims-based authentication, but it does not use Identity or Entity Framework. When you're logged in, you'll find the identity of the user on the User property of the controller as expected. The `PopForumsAuthorizationMiddleware` loads user and profile data into the request pipeline, so it can be loaded once and used throughout the request lifecycle. Accessing the user data can be achieved via an instance of `IUserRetrievalShim`, which you can inject into your dependency chain. From a controller, simply call `_userRetrievalShim.GetUser()` to get the fully hydrated POP Forums `User`, or `_userRetrievalShim.GetProfile()` to get the profile. Under the hood, these are stored in the `Items` collection of the current `HttpContext`. The easiest way to integrate with an existing set of users is to connect via an OAuth2 provider. Read more about [OAuth-Only Mode](oauthonly.md). ## Running third-party services in Docker containers If you want to run locally with some of the "kits" described in the documentation, you'll need to fire them up using Docker. Here are the commands for the most common things. These sometimes change, because of new names, versions and such, but they're current as of early 2023. * SQL Server (keep in mind that `mcr.microsoft.com/azure-sql-edge` is the ARM versoin of SQL) `docker run --cap-add SYS_PTRACE -e 'ACCEPT_EULA=1' -e 'MSSQL_SA_PASSWORD=P@ssw0rd' -p 1433:1433 --name sqledge -d mcr.microsoft.com/azure-sql-edge` * Azureite, for storage and queues `docker run -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite` * Redis, for distributed cache and SignalR backplane `docker run --name some-redis -p 6379:6379 -d redis` * ElasticSearch, for better search `docker run --name es-9 -p 9200:9200 -e discovery.type=single-node -it docker.elastic.co/elasticsearch/elasticsearch:9.3.0` You may want to have your databases be more durable in the event you trash the SQL container or update to a new one. To do that, first create a new volume, either in Docker Desktop or on the command line: ``` docker volume create sqldata ``` Then fire up the container and associate it with the volume, and tell it to use that volume for all of the data. ``` docker run -e 'ACCEPT_EULA=1' -e 'MSSQL_SA_PASSWORD=P@ssw0rd' -p 1433:1433 --name sqledge -v sqldata:/DATA -d mcr.microsoft.com/azure-sql-edge ``` If you would like to host the data files in your own file system, you can start the container like this, replacing the approprirate paths to your local spots, where `` is your spot: ``` docker run -e 'ACCEPT_EULA=1' -e 'MSSQL_SA_PASSWORD=P@ssw0rd' -p 1433:1433 --name sql2022 -v :/var/opt/mssql/data -d mcr.microsoft.com/mssql/server:2022-latest ``` And if you need to copy files out of an existing container, you can do that too. `~/sqlvolumes` in this case points to a folder in my user folders on a Mac: ``` docker cp containerID3bed54c7734b:/var/opt/mssql ~/sqlvolumes ``` ## Running Azure Functions on a Mac This isn't the most straightfoward thing, and it's hard to find the information, but you need to install the Azure Functions Core tools via Homebrew. [Microsoft explains how to do this.](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local?tabs=v4%2Cmacos%2Ccsharp%2Cportal%2Cbash#install-the-azure-functions-core-tools) ## Customization To make POP Forums look the way you want, or with extra functionality, read up on [customization](customization.md). ================================================ FILE: docs/versionhistory.md ================================================ --- layout: default title: Version History nav_order: 4 --- # POP Forums Version History Here's a partial version history that shows how POP Forums has evolved over the years. It's fun to look back at some of the things we now take for granted in a forum app. ## Version v21.1.0 (PopForums.ElasticKit only, 12/16/25) * The ElasticSearch key value in the configuration should simply be the API key, which is a more modern convention used in Elastic's cloud service. ## Version v22.0.0 (11/24/25) * Implement an ignore feature #318 * BUG: YouTube short links throwing exception on submit #386 * BUG: Hosted service failure in startup will prevent app start #381 * BUG: In process hosted jobs run at silly intervals #383 ## Version v21.0.1 (PopForums.Mvc only, 12/8/24) * BUG: Hosted service failure in startup will prevent app start #381 * BUG: In process hosted jobs run at silly intervals #383 ## Version v21.0.0 (12/7/24) * Update IForumAdapter to use async methods (breaking change) #361 * Clean up naming and organization of Authorization and Authentication bits #377 * Remove Twitter from profiles #372 * Migrate to .Net 9 and new libraries #371 * Refactor in-process background services to run on IHostedService #357 * Use streams when reading image data from SQL #359 * Refactor settings caching to make it simple #370 * BUG: Valid emails are being rejected #367 * BUG: PopForumsAuthorizationIgnoreAttribute doesn't actually cause middleware to skip user hydration #376 * BUG: GetUsersByPointTotals won't serialize for caching #358 * BUG: New PM incorrectly shows as "user not found" #360 * BUG: URL ending in closing parentheses breaks links #375 * BUG: Duplicate ForumsUserIDType claim being added to identity in SignalR hub #378 * BUG: Sequence contains no elements exception thrown in middleware #379 ## Version v20.0.0 (12/8/23) * Allow option to rely entirely on 3rd-party OAuth2 and OIDC for sign-in #183 * Vote up buttons need an interim state during call #334 * Update ElasticKit to use v8.x #335 * Updating to TypeScript 5.x requires accommodation of suppressImplicitAnyIndexErrors deprecation #345 * Animate notification and PM count so you see it #327 * Reduce size of trimmed URL's to fit in mobile situations #323 * Delete queued email messages after send #145 * Update to .NET v8 #348 * Update Polly to v8.x (in ElasticKit) #352 * Replace Moq with NSubstitute #340 * User creation should be atomic with profile, not sourced in a controller #328 * Refactor out the old FullUrlHelper #326 * PopForumsUserAttribute needs to be refactored to async #347 * BUG: Fix regex not escaping special characters #342 * BUG: Close private chat if the other user is deleted #321 * BUG: Chromium 114 causes element property name conflict #336 * BUG: Associate external login failing #324 * BUG: Load more before reply chokes when only one page of posts #319 * BUG: Images uploaded in post edit are flagged for deletion #316 ## Version v19.0.2 (6/6/23) * BUG: Chromium 114 causes element property name conflict #336 ## Version v19.0.1 (1/29/23) * BUG: Images uploaded in edit are flagged for deletion #316 * BUG: Load more before reply chokes when only one page of posts #319 * BUG: Associate external login failing #324 ## Version v19.0.0 (10/9/22) * Image upload in posts #109 * Create notification system #265 * Change PM system to real-time chat #304 * Refine post quoting to reduce full-post quotes #283 * Clean up old notifications #298 * Refresh and expand iconography #300 * Migrate from email based subscriptions to in-app notification #264 * Remove email subscriptions #276 * Update PM count in real-time #275 * New setting: reply-to email address #252 * Offer view of recent users in admin for spam monitoring #258 * Don't show user profiles for unverified accounts #263 * Include prompts to verify account where necessary #266 * Allow vote up reversal #287 * Use a reactive state box as base for new features #269 * Remove composite activity feed #299 * Use fulltext editor for profile signatures #291 * Decommission email from profile #310 * Add option to write error log to table storage #314 * Refactor: Move all of the client stuff to the `Mvc` project, ditch the npm package #285 * Refactor: Update MailKit library to v3.x #268 * Refactor: Update ImageSharp library to v2.x #267 * Refactor: Update TinyMCE library to v6.x #274 * Refactor: DateTime parsing on client using server localization, with DST #188 * Refactor: Create proper client-side localization mechanism #293 * Refactor: Migrate old PopForums.js to a more manageable, not-spaghetti, TypeScript base #286 * Refactor: Clean up on aisle admin #295 * Refactor: Exclude bots from session tracking #301 * Refactor: Signalr hub consolidation #312 * BUG: New `SqlCommand` can't parse setup script without semicolons #288 * BUG: Scroll to newest misses target because of inline images loading #290 * BUG: Infinite scroll repositions back to hash when more posts load #257 * BUG: New topic or reply fails if using Azure queues and they fail #284 ## Version v18.0.2 (5/28/22) * BUG: Fix bug in newer version of SqlClient when running setup script. #288 ## Version v18.0.1 (1/8/22) * BUG: Fix auto scroll when new posts load. #257 ## Version 18.0.0 (11/8/21) * Update to .NET 6. #211 * Migrate to Bootstrap v5. #215 * Eliminate dependencies on jQuery. #216 * Migrate admin to Vue.js v3. #232 * Embrace C# concepts. #249 * Use crop mode for avatars when they're uploaded. #239 * Update text parser to default to https on www URL's. #248 * Trim IP and email ban entries before saving. #222 * Remove dependency on Newtonsoft serializer for controllers with enums. #247 * Add a security policy. #238 * Update `AzureKit` to use modern libraries. #226 * Put a cache sink in Redis `CacheHelper` in `AzureKit`. #231 * Update Redis `CacheHelper` to use `System.Text.Json` when it's compatible. #169 * Discontinue use of `PopForums.json` for config, use default instead. #225 * BUG: Mailing list unsubscribe doesn't decode spaces from URL's. #237 * BUG: CSS needs case sensitive path for Linux environments. #228 * BUG: `SmtpWrapper` doesn't catch connections or auth exceptions. #237 * BUG: Bots causing null ref exception in `TopicViewCountService` when trying to fetch cookies. #210 * BUG: Setup stopped working in a previous version. #244 * BUG: Text parser fails on URL's with multiples protocol instances (like those from web archive). #233 ## Version 17.0.2 (9/29/21) * Fix for broken setup bug. #244 ## Version 17.0.1 (4/4/21) * Fix for multiple image bug. #234 ## Version 17.0.0 (8/24/20) * Rename AwsKit project to ElasticKit. #184 * Add alerts for admin errors. #152 * Use the right Bootstrap color context on email verification. #195 * Add configuration for entirely private forum. #86 * Use the HTML quoting in post edit box instead of forum tags. #201 * Generate site maps for all topics. #199 * Audit CSS and use Bootstrap when possible. #186 * Search for user on enter on admin user edit page. #194 * Use hashing mechanism for topic unsubscribes, the way mailing list works. #189 * Migrate to `Microsoft.Azure.Storage` packages from `Microsoft.WindowsAzure.Storage`. #182 * Use Bootstrap hidden utilities to hide elements on mobile. #181 * Move recent and feed links to navigation bar. #187 * Embrace `IHttpContextAccessor` in user shim. #176 * Add a telemetry interface for Redis cache, but don't implement it. #185 * Use contrasting colors for new post indicators. #179 * Refresh user profiles. #174 * Improve ElasticSearch for proper resiliency, add API key ability. #173 * Log search service errors. #171 * BUG: User profile tabs have wrong background for activity and awards. #172 * BUG: Avatars and signatures not appearing for anonymous user. #197 * BUG: Pasted URL's paste as links, parser can't shorten the text. #163 * BUG: Formatting in quoted posts sometimes causes portions of post to get parsed out. #198 * BUG: LastTopicView could have duplicate entries. #202 ## Version 16.0.1 (12/27/19) * BUG: Redis cache helper can't serialize forum view/post graphs. #168 ## Version 16.0.0 (12/17/19) * Update to .Net Core v3.1. #151 * Redis connection failures recorded as "information" instead of "error." #162 * Optimize user objects and caching. #44 * Using "page" in routes can potentially interfere with Razor pages. #150 * Close dormant threads on a background job. #67 * Allow topic author to edit title. #80 * Refactor all the things to async/await. #132 * Refactor external logins to decouple from Identity (uses [POP Identity](https://github.com/POPWorldMedia/POPIdentity)). #140 * Cleanup startup configuration. #129 * Refactor the posting service classes. #121 * Cleanup unused methods and tests. #133 * Add unique constraints for user name and email. #144 * Add ReCAPTCHA option for login. #143 * Update to a stronger hash algorithm for passwords. #142 * Reintroduce profile caching. #148 * BUG: Failed reply doesn't wire up error message and attempts to redirect. #160 * BUG: Delete, undelete, hard delete doesn't remove from search index. #154 * BUG: Give background job to delete indexed words in self-rolled search more time. #149 * BUG: Creation of Redis connection not thread safe, possible race condition. #135 * BUG: Delete/undelete does not trigger a search reindex. #136 * BUG: Search indexer Azure function does not respect settings for provider setting. #137 * BUG: External login list has duplicate entries for description. #139 ## Version 15.0.0 (5/27/19) * General update of dependencies. * Rewrite of admin to use Vue.js. #120 * Scaffolding for recording view data, correlating between users and topics (for future analytic use). #104 * Optionally run background processes as Azure functions. #76 * Social icons in profile. #119 * Redis backplane for SingalR in multi-node hosting. #64 * Migrated to Bootstrap v4.x. #94 * Abandon decade-old constructors in models. #96 * Remove first post preview from topic lists (no one used it). #97 * Truncate error log instead of delete all. #103 * Adopt Dapper usage in `PopForums.Sql` library. #105 * Improve performance for IP history and security log. #106 * Realign social links in profile to modern services. #107 * Provide a standard way to fail distributed events. #117 * Add support for ElasticSearch. #116 * BUG: Over-zealous regex hangs unparse of client HTML. #118 * BUG: Fix Favorite & Subscription Topic pages. #111 * BUG: It's possible to submit empty posts with just returns. #82 * BUG: TinyMCE is mangling hyperlinks. #125 * BUG: TinyMCE is inserting extra attributes in image tags, breaking parsing. #128 ## Version 14.1.0 (12/9/18) * Update to .Net Core v2.2. * General package updates. * Move all MVC views to a project that can be published as a Razor class library package. * Remove ability to resize images in TinyMCE editor, since attributes are ignored anyway. * Bug: for pager links in faves and subs (#90 and #91). ## Version 14.0.0 (7/15/18) * This is a port of v13 to ASP.NET Core v2.1.0. It's mostly intended to achieve feature parity. * Experimental AzureKit allows for multi-instance use and scaling. ## Version 13.0.0 (2/14/15) * Completely revised UI uses Bootstrap, replaces separate mobile views. * New Q&A style forums. * Preview your posts for formatting. * Social logins using OWIN 2.x. * StructureMap replaces Ninject for dependency injection. * Admins can permanently delete a topic. * Facebook and Twitter links added to profiles. * IP ban works on partial matches. * Bug: Initial user creation didn't salt passwords ([Codeplex 131](http://popforums.codeplex.com/workitem/131)) * Bug: Replies not triggering reindex for topics (#4). * Bug: LastReadService often called from Post action of ForumController without user, throws null ref (#1). * Bug: Reply and quote buttons appear in posts even when the topic is closed (#8). * Bug: When email verify is on, changing email does not set IsApproved to false (#10). * Bug: Image controller throws when Googlebot sends a weird If-Modified-Since header (#13). * Bug: Reply or new topic can be added to archived forum via direct POST outside of UI (#15). * Bug: Multiple entries to last forum and topic view tables causing exception when reading values into dictionary (#17). * Experimental: Support for multiple instances in Azure with shared Redis cache (not production ready). ## Version 12.1.0 (4/25/14) * Added Taiwanese Mandarin translation * Fixed HtmlHelper to remove reference to DependencyResolver. #122: HtmlHelper for role checkboxes referenced static DependencyResolver * Disable submit buttons on new topics/replies for mobile views. #121: Port the submit button disable for posts in mobile view * Fixed mobile view of edit doesn't parse text for overridden mobile mode. #123: Mobile post edit view has HTML, save doesn't persist line breaks ## Version 12.0.0 (12/8/13) * Updated to use .NET 4.5.1 and MVC 5, including the latest library code (jQuery, SignalR, OWIN, etc.) * User passwords now include per-user salt, backward compatible to existing user data. #120: Salt passwords by user * External logins implemented via OWIN, for Google, Facebook, Twitter and Microsoft accounts. #117:Integrate the OWIN external auth stuff * YouTube URL's not formatted as hyperlinks converted to embedded video, provided images are allowed in posts. #116: Parse YouTube URL's to convert into YouTube iframes * Controller dependencies converted to private members. #115: Refactor controller dependencies to private members ## Version 11.1.0 (9/4/13) * Added Ukrainian to supported languages. Also includes English, Spanish, German and Dutch. * Fixed issue #115: Mobile view allows double post. ## Version 11.0.1 (5/15/13) * Fix for issue #113: User can post in closed topic via mobile views. ## Version 11.0.0 (4/17/13) * Updated to use v4.5 of .NET. * External references now use NuGet. * Adding an award definition in admin now bounces you to its edit page. * Fixed: Show more posts updates topic context with updated page counts. * Activities and awards restyled. * User profiles are tabbed. * Activity feed shows real-time view of activity sent via the scoring game API. * Times are updated every minute, formatted to current culture. * More posts are loaded on scroll (a la Facebook), but pager links are maintained for search engine discoverability. * New posts appear inline at end of post stream as they're made. * Forum home and individual topic lists updated in real time. * Breadcrumb/navigation floats at top of browser. * .forumGrid CSS removes outline, so it's more Metro-y. ## Version 10.0.1 (9/15/12) This update has no UI component or data changes. It only addresses the following bug: * ServiceModule has potential race condition in ASP.NET v4.5 * A bug in the MVC 4 framework requires this package for mobile views: http://nuget.org/packages/Microsoft.AspNet.Mvc.FixedDisplayModes ## Version 10.0.0 (8/16/12) * Uses a very light weight CSS and Javascript package to provide a touch-friendly interface for mobile devices. * Numbers are formatted (sensitive to culture) when 1,000 or higher. * CSS is more integration friendly, and specific to the ForumContainer element. * Mail delivery from queue is now parallel, so you can specify a sending interval, and the number of messages to process on each interval. * Background "services" refactored, and will only run with a call on app start to PopForumsActivation.StartServices(). This is partly to facilitate future use in Web farms/multiple Web roles in Azure. * Update to jQuery v1.7.1. * Replaced use of .live() with .on() in script, pursuant to jQuery update, which deprecates .live(). * Renamed HomeController to ForumHomeController, to make lives easier when integrating into an MVC app. * Dependency resolution no longer requires that you set Ninject as the container for the entire MVC app. The controllers now resolve their dependencies in their constructors, so you're free to set up any DI container in your global.asax. * The included single-server SQL data layer now uses the base classes and interfaces for (DbConnection, DbCommand, etc.) instead of the specific SQL flavors, for easier refactoring in case you want to build an Oracle version or something. * FIX: Bug in topic repository around caching keys for single-server data layer. * FIX: Pager links on recent topics pointed to incorrect route. * FIX: Deleting a post didn't update last user/post time. * FIX: Ditched attempt at writing to event log with super failures, since almost no one has permission in production. * FIX: Bug in grayed-out fields in admin mail setup. * FIX: Weird color profiles would break loading of images for resize. * FIX: TOS text on account sign-up was double encoded. ## Version 9.2.1 (1/26/12) * Added Spanish (es) translation. ## Version 9.2.0 (1/23/12) * Localization: The app can be easily translated using .resx files. Initially includes English (en), German (de) and Dutch (nl). * Vote up posts: Give credit to people who make good posts, see who voted up each one. * The scoring game: Extensible system that allows you to give users points, and issue awards based on repeated events. For example, you can set up awards based on the number of new posts or topics a user makes (both of which are recorded). * Fix: Weird line breaks in lists when posting from Firefox. ## Version 9.1.1 (12/18/11) * Corrects a bug that prevented a topic from being marked viewed when a user looked at it, resulting in "new post" indicators that didn't go away until the user marked the entire forum as read. ## Version 9.1.0 (12/15/11) * New "adapter" interface for forums. Using the IForumAdapter interface, a developer can plug-in code that alters the model and/or resulting view on topic lists and the actual threads. For example, you might add to or alter the model, then present a different view to display the data. See the comments on the IForumAdapter interface for more information. * Also new, users starting a reply will see a button indicating that they can load any new posts that have occurred since they started writing their apply, so they don't miss any of the conversation. * Fix: Moderating topic title doesn't update the UrlName. * SEO enhancement: Page links in topics and forums include rel="next" and rel="prev" to tell search engines there's more to look at. * Fix: User post list had broken markup, preventing topic preview. * Fix: Added missing permission checking on action methods to preview or load individual posts. * User name in top nav now acts as a link to the user's profile. * Fix: Cache key for caching view post roles was incorrect. ## Version 9.0.0 (4/24/11) * Rewritten from scratch for ASP.NET MVC3, with reasonable test coverage * Posts can be loaded inline * Avatars and user images resized on server ## Version 8.5.0 (not publicly released) * Added plug-in infrastructure to allow changes to UI. Made for photo forum on CoasterBuzz ## Version 8.0.0 (11/10/08) * Added AJAX features, takes advantage of ASP.NET v3.5. ## Version 7.5.1 (2/9/05) * Significantly altered the TextParser class * Changed text in RegisterLogin.ascx to indicate e-mail is used to send activation code * Topic titles now parsed for naughty words and HTML ## Version 7.5.0 (10/25/04) * New text parsing engine * New online user engine * RichText control displays in Windows 2000 * Fixed member post paging * Online user stats now draw from PopForums.OnlineUsers * Added RequiredFieldValidator to SendPrivateMessage.ascx to check for a subject * PM reply doesn't add endless string of "re:" * Fixed PagerLinks.cs to correctly display tool tips * Added PagedPagerLinks.cs to display paged results from new PopForums.Forum.GetTopics() overload * Made the member mailer text box in the admin bigger * Can't reply to closed topics, even if reply box was visible before submitting ## Version 7.0.3 (1/24/04) * Fixed URL and image detection in TextParser. ## Version 7.0.2 (11/11/03) * Updated the RichText class to make it compatible with a recent update to Internet Explorer. ## Version 7.0.1 (11/4/03) * Removed license scheme to make POP Forums free. ## Version 7.0.0 (10/22/03) * Total rewrite with separation of data, logic and user interface. * Several user interfaces available at launch. * Data caching can reduce database activity by 60% or more. * A RichText server control that renders in Internet Explorer. * Role-based security you can use anywhere on your site, based on ASP.NET's forms authentication. * Discreet data class, so you can write your own to access any database. (SQL Server used by default.) * A TimeAdjust class to convert times to local time zones, and even adjust for daylight savings. * Text parsing engine takes care of HTML generated by RichText as well as traditional "forum code." ## Version 6.0.0 (7/1/02) * Original ASP application ported to ASP.NET. * Significant improvements to HTML parsing engine. * Forum exists as a user control, just drop it in your .aspx page! * E-mail notification for anyone who replies to a topic. * All-CSS interface shipped along side "admin-able" formatting. * Asynchronous mailing to opt-in members (no need to tie up your browser with mailing process). * Last post indicated by member name. * Scroll to new post bounces you to page with newest post. * Role-based security. Only those in certain roles can access or post in the forums you designate. * Robust forum property and ordering in admin area. * Error logging to database. ## Version 5.1 (3/4/02) * New HTML/forum code parsing engine. ## Version 5.0 (8/19/01) * Support for SQL Server Full-Text Indexing built-in for much faster searching. * WYSIWYG post editor for Internet Explorer users, normal for other browsers. * Favorite topic list. * Terms of Service agreement with first post, reset all members' agreement. * Member post quotas, to limit "noisy" members. * Support for Persits and Dimac mail components. * Post preview. * "Jump to" added to topic and thread pages to jump to another forum. * Public stats shown optionally (number of sessions, topics, members, etc.) * Client-side form validation of new topics or replies to challenge empty fields. * Admin edit member accounts. * Recent topics shows links to multi-paged topics. * Error page for URL's of deleted topics and forums. * Fixed edit page hang for long posts. * Search only returns private forum results to those who have access. * Removed "requires AspMail" from admin page. * Moderator IP view spans all pages in thread. * Manage e-mail notification page reformatted. * Double carriage returns now parsed as p tags. * Fixed CDONTS mailer for compatibility with settings of other components. * Fixed br unparsing with forum code off. ## Version 4.0 (1/8/01) * Added support for CDO, CDONTS and AspQMail components. * Post new topic and reply links added to topic and thread pages. * COPPA check defeat. * Support for Spellchecker.net. * Scroll to newest post link. * Addition of pre and quote forum tags. * Paged threads. Posts per page are admin-defined. Links to each page appear in topic list. * Auto-login after first post. * AIM and ICQ hyperlinks in member info. * Non-logged-in forums indicates "register to track new posts." * COPPA (Children's Online Privacy Protection Act) compliance. * Moderators can view IP addresses of posters. * E-mail notification of new posts when member starts thread. * Member area to remove e-mail notification of new posts. * Opt-out or delete account option built into special URL sent with mass mailings to members. * Mail to friend now uses system e-mail contact as from address, member as replyto address (to handle SMTP authentication issues). * Moving lone topic from forum no longer results in an error. ## Version 3.2 (11/12/00) * Included explanation of smilies in help file. ## Version 3.1 (9/20/00) * One-click close/open/delete threads for moderators. * Parse ftp:// URL's. * Post-and-close thread check box for moderators, * Next/previous thread. * Mail thread to a friend. * Recent topics. * Fixed left column text color in search results. ## Version 3.0 (8/22/00) * Major rewrite of most code. * Dozens of files consolidated. * Made to be portable between systems. * Extensive administration area. * Custom formatting added to instantly change fonts, colors, etc. * Search engine added. * Private and archived forums added. ## Version 2.0 (1/31/00) * Written for CoasterBuzz. * Cleaner format. * Forum off/on. * "Mark posts read" time stored in profile instead of browser. * Signature block added. * Profile photo upload added. ## Version 1.0 (11/23/99) * Written for Guide to The Point. * Basic features only. ================================================ FILE: src/.editorconfig ================================================ # EditorConfig is awesome:http://EditorConfig.org # top-most EditorConfig file root = true # Don't use tabs for indentation. [ "*" ] indent_style = tab end_of_line = lf insert_final_newline = true # (Please don't specify an indent_size here; that has too many unintended consequences.) # Code files [*.{cs,csx,vb,vbx,cshtml}] indent_style = tab indent_size = 4 # Xml project files [*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] indent_style = tab indent_size = 2 # Xml config files [*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] indent_style = tab indent_size = 2 # JSON files [*.json] indent_style = space indent_size = 2 ================================================ FILE: src/PopForums/Composers/ForumStateComposer.cs ================================================ namespace PopForums.Composers; public interface IForumStateComposer { ForumState GetState(Forum forum, PagerContext pagerContext); } public class ForumStateComposer : IForumStateComposer { public ForumState GetState(Forum forum, PagerContext pagerContext) { var forumState = new ForumState {ForumID = forum?.ForumID, PageSize = pagerContext.PageSize, PageIndex = pagerContext.PageIndex}; return forumState; } } ================================================ FILE: src/PopForums/Composers/PrivateMessageStateComposer.cs ================================================ namespace PopForums.Composers; public interface IPrivateMessageStateComposer { Task GetState(PrivateMessage pm); } public class PrivateMessageStateComposer : IPrivateMessageStateComposer { private readonly IPrivateMessageService _privateMessageService; public PrivateMessageStateComposer(IPrivateMessageService privateMessageService) { _privateMessageService = privateMessageService; } public async Task GetState(PrivateMessage pm) { var state = new PrivateMessageState(); var messages = await _privateMessageService.GetMostRecentPosts(pm.PMID, pm.LastViewDate); state.NewestPostID = messages.Any() ? messages.First().PMPostID : 0; var bufferMessages = await _privateMessageService.GetPosts(pm.PMID, pm.LastViewDate); messages.InsertRange(0, bufferMessages); state.PmID = pm.PMID; var clientMessages = ClientPrivateMessagePost.MapForClient(messages); state.Messages = clientMessages; state.Users = pm.Users; var pmUsersFromPMRecord = pm.Users.Deserialize>(); var pmUsers = await _privateMessageService.GetUsers(pm.PMID); var isUserNotFound = pmUsers.Count != pmUsersFromPMRecord.Count; state.IsUserNotFound = isUserNotFound; return state; } } ================================================ FILE: src/PopForums/Composers/ResourceComposer.cs ================================================ namespace PopForums.Composers; public interface IResourceComposer { dynamic GetForCurrentThread(); } public class ResourceComposer : IResourceComposer { public dynamic GetForCurrentThread() { var resources = new { LessThanMinute = Resources.LessThanMinute, OneMinuteAgo = Resources.OneMinuteAgo, MinutesAgo = Resources.MinutesAgo, TodayTime = Resources.TodayTime, YesterdayTime = Resources.YesterdayTime, Notifications = Resources.Notifications, NewReplyNotification = Resources.NewReplyNotification, Award = Resources.Award, VoteUpNotification = Resources.VoteUpNotification, QuestionAnsweredNotification = Resources.QuestionAnsweredNotification, Send = Resources.Send, UploadImage = Resources.UploadImage }; return resources; } } ================================================ FILE: src/PopForums/Composers/TopicStateComposer.cs ================================================ namespace PopForums.Composers; public interface ITopicStateComposer { Task GetState(Topic topic, int? pageIndex, int? pageCount, int lastVisiblePostID); } public class TopicStateComposer : ITopicStateComposer { private readonly IUserRetrievalShim _userRetrievalShim; private readonly ISettingsManager _settingsManager; private readonly ISubscribedTopicsService _subscribedTopicsService; private readonly IFavoriteTopicService _favoriteTopicService; public TopicStateComposer(IUserRetrievalShim userRetrievalShim, ISettingsManager settingsManager, ISubscribedTopicsService subscribedTopicsService, IFavoriteTopicService favoriteTopicService) { _userRetrievalShim = userRetrievalShim; _settingsManager = settingsManager; _subscribedTopicsService = subscribedTopicsService; _favoriteTopicService = favoriteTopicService; } public async Task GetState(Topic topic, int? pageIndex, int? pageCount, int lastVisiblePostID) { var topicState = new TopicState {TopicID = topic.TopicID, PageIndex = pageIndex, PageCount = pageCount, LastVisiblePostID = lastVisiblePostID, AnswerPostID = topic.AnswerPostID}; var user = _userRetrievalShim.GetUser(); if (user != null) { topicState.IsImageEnabled = _settingsManager.Current.AllowImages; topicState.IsFavorite = await _favoriteTopicService.IsTopicFavorite(user.UserID, topic.TopicID); topicState.IsSubscribed = await _subscribedTopicsService.IsTopicSubscribed(user.UserID, topic.TopicID); } return topicState; } } ================================================ FILE: src/PopForums/Configuration/Config.cs ================================================ namespace PopForums.Configuration; public interface IConfig { string DatabaseConnectionString { get; } int CacheSeconds { get; } string CacheConnectionString { get; } bool ForceLocalOnly { get; } string SearchUrl { get; } string SearchKey { get; } string QueueConnectionString { get; } string SearchProvider { get; } bool LogTopicViews { get; } bool UseReCaptcha { get; } string ReCaptchaSiteKey { get; } string ReCaptchaSecretKey { get; } string IpLookupUrlFormat { get; } string WebAppUrlAndArea { get; } string BaseImageBlobUrl { get; } string StorageConnectionString { get; } bool RenderBootstrap { get; } bool IsOAuthOnly { get; } string OAuthClientID { get; } string OAuthClientSecret { get; } string OAuthLoginBaseUrl { get; } string OAuthTokenUrl { get; } string OAuthAdminClaimType { get; } string OAuthAdminClaimValue { get; } string OAuthModeratorClaimType { get; } string OAuthModeratorClaimValue { get; } string OAuthScopes { get; } int OAuthRefreshExpirationMinutes { get; } } public class Config : IConfig { public Config(IConfiguration configuration) { if (_configContainer == null) { var loader = new ConfigLoader(); _configContainer = loader.GetConfig(configuration); } } private static ConfigContainer _configContainer; public string DatabaseConnectionString => _configContainer.DatabaseConnectionString; public int CacheSeconds => _configContainer.CacheSeconds; public string CacheConnectionString => _configContainer.CacheConnectionString; public bool ForceLocalOnly => _configContainer.CacheForceLocalOnly; public string SearchUrl => _configContainer.SearchUrl; public string SearchKey => _configContainer.SearchKey; public string QueueConnectionString => _configContainer.QueueConnectionString; public string SearchProvider => _configContainer.SearchProvider; public bool LogTopicViews => _configContainer.LogTopicViews; public bool UseReCaptcha => _configContainer.UseReCaptcha; public string ReCaptchaSiteKey => _configContainer.ReCaptchaSiteKey; public string ReCaptchaSecretKey => _configContainer.ReCaptchaSecretKey; public string IpLookupUrlFormat => _configContainer.IpLookupUrlFormat; public string WebAppUrlAndArea => _configContainer.WebAppUrlAndArea; public string BaseImageBlobUrl => _configContainer.BaseImageBlobUrl; public string StorageConnectionString => _configContainer.StorageConnectionString; public bool RenderBootstrap => _configContainer.RenderBootstrap; public bool IsOAuthOnly => _configContainer.IsOAuthOnly; public string OAuthClientID => _configContainer.OAuthClientID; public string OAuthClientSecret => _configContainer.OAuthClientSecret; public string OAuthLoginBaseUrl => _configContainer.OAuthLoginBaseUrl; public string OAuthTokenUrl => _configContainer.OAuthTokenUrl; public string OAuthAdminClaimType => _configContainer.OAuthAdminClaimType; public string OAuthAdminClaimValue => _configContainer.OAuthAdminClaimValue; public string OAuthModeratorClaimType => _configContainer.OAuthModeratorClaimType; public string OAuthModeratorClaimValue => _configContainer.OAuthModeratorClaimValue; public string OAuthScopes => _configContainer.OAuthScopes; public int OAuthRefreshExpirationMinutes => _configContainer.OAuthRefreshExpirationMinutes; } ================================================ FILE: src/PopForums/Configuration/ConfigContainer.cs ================================================ namespace PopForums.Configuration; public class ConfigContainer { public string DatabaseConnectionString { get; set; } public int CacheSeconds { get; set; } public string CacheConnectionString { get; set; } public bool CacheForceLocalOnly { get; set; } public string SearchUrl { get; set; } public string SearchKey { get; set; } public string QueueConnectionString { get; set; } public string SearchProvider { get; set; } public bool LogTopicViews { get; set; } public bool UseReCaptcha { get; set; } public string ReCaptchaSiteKey { get; set; } public string ReCaptchaSecretKey { get; set; } public string IpLookupUrlFormat { get; set; } public string WebAppUrlAndArea { get; set; } public string BaseImageBlobUrl { get; set; } public string StorageConnectionString { get; set; } public bool RenderBootstrap { get; set; } public bool IsOAuthOnly { get; set; } public string OAuthClientID { get; set; } public string OAuthClientSecret { get; set; } public string OAuthLoginBaseUrl { get; set; } public string OAuthTokenUrl { get; set; } public string OAuthAdminClaimType { get; set; } public string OAuthAdminClaimValue { get; set; } public string OAuthModeratorClaimType { get; set; } public string OAuthModeratorClaimValue { get; set; } public string OAuthScopes { get; set; } public int OAuthRefreshExpirationMinutes { get; set; } } ================================================ FILE: src/PopForums/Configuration/ConfigLoader.cs ================================================ namespace PopForums.Configuration; public class ConfigLoader { public ConfigContainer GetConfig(IConfiguration configuration) { var container = new ConfigContainer(); container.DatabaseConnectionString = configuration["PopForums:Database:ConnectionString"]; var cacheSeconds = configuration["PopForums:Cache:Seconds"]; container.CacheSeconds = string.IsNullOrEmpty(cacheSeconds) ? 90 : Convert.ToInt32(cacheSeconds); container.CacheConnectionString = configuration["PopForums:Cache:ConnectionString"]; container.CacheForceLocalOnly = Convert.ToBoolean(configuration["PopForums:Cache:ForceLocalOnly"]); container.SearchUrl = configuration["PopForums:Search:Url"]; container.SearchKey = configuration["PopForums:Search:Key"]; var searchProvider = configuration["PopForums:Search:Provider"]; container.SearchProvider = searchProvider ?? string.Empty; container.QueueConnectionString = configuration["PopForums:Queue:ConnectionString"]; var logTopicViews = configuration["PopForums:LogTopicViews"]; container.LogTopicViews = logTopicViews != null && bool.Parse(logTopicViews); var useReCaptcha = configuration["PopForums:ReCaptcha:UseReCaptcha"]; container.UseReCaptcha = useReCaptcha != null && bool.Parse(useReCaptcha); container.ReCaptchaSiteKey = configuration["PopForums:ReCaptcha:SiteKey"]; container.ReCaptchaSecretKey = configuration["PopForums:ReCaptcha:SecretKey"]; container.IpLookupUrlFormat = configuration["PopForums:IpLookupUrlFormat"]; container.WebAppUrlAndArea = configuration["PopForums:WebAppUrlAndArea"]; container.BaseImageBlobUrl = configuration["PopForums:BaseImageBlobUrl"]; container.StorageConnectionString = configuration["PopForums:Storage:ConnectionString"]; var renderBootstrap = configuration["PopForums:RenderBootstrap"]; container.RenderBootstrap = renderBootstrap != null ? bool.Parse(renderBootstrap) : true; var isOAuthOnly = configuration["PopForums:OAuthOnly:IsOAuthOnly"]; container.IsOAuthOnly = isOAuthOnly != null ? bool.Parse(isOAuthOnly) : false; container.OAuthClientID = configuration["PopForums:OAuthOnly:OAuthClientID"]; container.OAuthClientSecret = configuration["PopForums:OAuthOnly:OAuthClientSecret"]; container.OAuthLoginBaseUrl = configuration["PopForums:OAuthOnly:OAuthLoginBaseUrl"]; container.OAuthTokenUrl = configuration["PopForums:OAuthOnly:OAuthTokenUrl"]; container.OAuthAdminClaimType = configuration["PopForums:OAuthOnly:OAuthAdminClaimType"]; container.OAuthAdminClaimValue = configuration["PopForums:OAuthOnly:OAuthAdminClaimValue"]; container.OAuthModeratorClaimType = configuration["PopForums:OAuthOnly:OAuthModeratorClaimType"]; container.OAuthModeratorClaimValue = configuration["PopForums:OAuthOnly:OAuthModeratorClaimValue"]; container.OAuthScopes = configuration["PopForums:OAuthOnly:OAuthScopes"]; var refreshMinutes = configuration["PopForums:OAuthOnly:OAuthRefreshExpirationMinutes"]; container.OAuthRefreshExpirationMinutes = string.IsNullOrEmpty(refreshMinutes) ? 60 : Convert.ToInt32(refreshMinutes); return container; } } ================================================ FILE: src/PopForums/Configuration/ErrorLog.cs ================================================ namespace PopForums.Configuration; public interface IErrorLog { void Log(Exception exception, ErrorSeverity severity); void Log(Exception exception, ErrorSeverity severity, string additionalContext); List GetErrors(int pageIndex, int pageSize, out PagerContext pagerContext); PagedList GetErrors(int pageIndex, int pageSize); Task DeleteError(int errorID); Task DeleteAllErrors(); } public class ErrorLog : IErrorLog { public ErrorLog(IErrorLogRepository errorLogRepository) { _errorLogRepository = errorLogRepository; } private readonly IErrorLogRepository _errorLogRepository; public void Log(Exception exception, ErrorSeverity severity) { Log(exception, severity, null); } public void Log(Exception exception, ErrorSeverity severity, string additionalContext) { if (exception != null && exception is ErrorLogException) return; var message = string.Empty; var stackTrace = string.Empty; var s = new StringBuilder(); if (additionalContext != null) { s.Append("Additional context:\r\n"); s.Append(additionalContext); s.Append("\r\n\r\n"); } if (exception != null) { message = exception.GetType().Name + ": " + exception.Message; if (exception.InnerException != null) message += "\r\n\r\nInner exception: " + exception.InnerException.Message; stackTrace = exception.StackTrace ?? string.Empty; foreach (DictionaryEntry item in exception.Data) { s.Append(item.Key); s.Append(": "); s.Append(item.Value); s.Append("\r\n"); } } s.Append("\r\n"); var moreData = s.ToString(); try { // TODO: Eventually make this async, but its web of call stacks are huge _errorLogRepository.Create(DateTime.UtcNow, message, stackTrace, moreData, severity); } catch { throw new ErrorLogException($"Can't log error: {message}\r\n\r\n{stackTrace}\r\n\r\n{moreData}"); } } public List GetErrors(int pageIndex, int pageSize, out PagerContext pagerContext) { var startRow = ((pageIndex - 1) * pageSize) + 1; var errors = _errorLogRepository.GetErrors(startRow, pageSize).Result; var errorCount = _errorLogRepository.GetErrorCount().Result; var totalPages = Convert.ToInt32(Math.Ceiling(Convert.ToDouble(errorCount) / Convert.ToDouble(pageSize))); pagerContext = new PagerContext { PageCount = totalPages, PageIndex = pageIndex, PageSize = pageSize }; return errors; } public PagedList GetErrors(int pageIndex, int pageSize) { var errors = GetErrors(pageIndex, pageSize, out PagerContext pagerContext); var list = new PagedList { PageCount = pagerContext.PageCount, PageIndex = pagerContext.PageIndex, PageSize = pagerContext.PageSize, List = errors }; return list; } public async Task DeleteError(int errorID) { await _errorLogRepository.DeleteError(errorID); } public async Task DeleteAllErrors() { await _errorLogRepository.DeleteAllErrors(); } } ================================================ FILE: src/PopForums/Configuration/ErrorLogException.cs ================================================ namespace PopForums.Configuration; public class ErrorLogException : Exception { public ErrorLogException(string message) : base(message) { } public override string Message => "Can't log exception: " + base.Message; } ================================================ FILE: src/PopForums/Configuration/ErrorSeverity.cs ================================================ namespace PopForums.Configuration; public enum ErrorSeverity { Critical = 1, Warning = 2, Information = 3, Debug = 4, Error = 5, Email = 6 } ================================================ FILE: src/PopForums/Configuration/ICacheHelper.cs ================================================ namespace PopForums.Configuration; public interface ICacheHelper { /// /// Saves an object to cache using he configured number of seconds. /// /// /// void SetCacheObject(string key, object value); /// /// Saves an object to cache using the specified number of seconds. /// /// /// /// void SetCacheObject(string key, object value, double seconds); /// /// Saves an object to cache without a time limit. Expiration should be deferred to /// the cache mechanism. /// /// /// void SetLongTermCacheObject(string key, object value); /// /// Stores an object in cache as a group. When the root key is removed, all of the paged items /// are also removed. Useful for storing paged threads and lists of topics. /// /// The type for the collections stored together (like a List<Post>). /// They key that references the collection of paged lists. /// The page number of the collection to store. /// The collection of T objects to cache. void SetPagedListCacheObject(string rootKey, int page, List value); /// /// Removes an object from cache. /// /// void RemoveCacheObject(string key); /// /// Removes an object from cache. /// /// Key of the cache item to remove. /// /// Implementations of this method should call the event. /// T GetCacheObject(string key); List GetPagedListCacheObject(string rootKey, int page); /// /// Can be used as a notification mechanism for downstream actions when cache is invalidated. /// /// /// Implementations of ICacheHelper should call this event whenever they invalidate a cached object. /// event Action OnRemoveCacheKey; string GetEffectiveCacheKey(string key); } ================================================ FILE: src/PopForums/Configuration/Settings.cs ================================================ namespace PopForums.Configuration; public class Settings { public Settings() { TermsOfService = string.Empty; IsNewUserApproved = true; TopicsPerPage = 20; PostsPerPage = 20; ForumTitle = string.Empty; MinimumSecondsBetweenPosts = 30; SmtpServer = "localhost"; SmtpPort = 25; MailerAddress = string.Empty; ReplyToAddress = string.Empty; UseEsmtp = false; SmtpUser = string.Empty; SmtpPassword = string.Empty; MailSendingInverval = 1500; UseSslSmtp = false; SessionLength = 20; CensorWords = string.Empty; CensorCharacter = "*"; AllowImages = false; LogSecurity = true; LogModeration = true; LogErrors = true; IsNewUserImageApproved = false; SearchIndexingInterval = 10000; IsSearchIndexingEnabled = true; IsMailerEnabled = true; UserImageMaxHeight = 300; UserImageMaxWidth = 400; UserImageMaxkBytes = 100; UserAvatarMaxHeight = 90; UserAvatarMaxWidth = 90; UserAvatarMaxkBytes = 10; MailSignature = string.Empty; ScoringGameCalculatorInterval = 1000; MailerQuantity = 4; UseGoogleLogin = false; UseFacebookLogin = false; FacebookAppID = string.Empty; FacebookAppSecret = string.Empty; UseMicrosoftLogin = false; MicrosoftClientID = string.Empty; MicrosoftClientSecret = string.Empty; YouTubeHeight = 360; YouTubeWidth = 640; GoogleClientId = string.Empty; GoogleClientSecret = string.Empty; UseOAuth2Login = false; OAuth2ClientID = string.Empty; OAuth2ClientSecret = string.Empty; OAuth2LoginUrl = string.Empty; OAuth2TokenUrl = string.Empty; OAuth2DisplayName = string.Empty; OAuth2Scope = "email"; IsClosingAgedTopics = false; CloseAgedTopicsDays = 365; IsPrivateForumInstance = false; PostImageMaxHeight = 1000; PostImageMaxWidth = 1000; PostImageMaxkBytes = 5000; } public virtual string TermsOfService { get; set; } public virtual bool IsNewUserApproved { get; set; } public virtual int TopicsPerPage { get; set; } public virtual int PostsPerPage { get; set; } public virtual string ForumTitle { get; set; } public virtual int MinimumSecondsBetweenPosts { get; set; } public virtual string SmtpServer { get; set; } public virtual int SmtpPort { get; set; } public virtual string MailerAddress { get; set; } public virtual string ReplyToAddress { get; set; } public virtual bool UseEsmtp { get; set; } public virtual string SmtpUser { get; set; } public virtual string SmtpPassword { get; set; } public virtual int MailSendingInverval { get; set; } public virtual bool UseSslSmtp { get; set; } public virtual int SessionLength { get; set; } public virtual string CensorWords { get; set; } public virtual string CensorCharacter { get; set; } public virtual bool AllowImages { get; set; } public virtual bool LogSecurity { get; set; } public virtual bool LogModeration { get; set; } public virtual bool LogErrors { get; set; } public virtual bool IsNewUserImageApproved { get; set; } public virtual int SearchIndexingInterval { get; set; } public virtual bool IsSearchIndexingEnabled { get; set; } public virtual bool IsMailerEnabled { get; set; } public virtual int UserImageMaxHeight { get; set; } public virtual int UserImageMaxWidth { get; set; } public virtual int UserImageMaxkBytes { get; set; } public virtual int UserAvatarMaxHeight { get; set; } public virtual int UserAvatarMaxWidth { get; set; } public virtual int UserAvatarMaxkBytes { get; set; } public virtual string MailSignature { get; set; } public virtual int ScoringGameCalculatorInterval { get; set; } public virtual int MailerQuantity { get; set; } public virtual bool UseGoogleLogin { get; set; } public virtual bool UseFacebookLogin { get; set; } public virtual string FacebookAppID { get; set; } public virtual string FacebookAppSecret { get; set; } public virtual bool UseMicrosoftLogin { get; set; } public virtual string MicrosoftClientID { get; set; } public virtual string MicrosoftClientSecret { get; set; } public virtual int YouTubeHeight { get; set; } public virtual int YouTubeWidth { get; set; } public virtual string GoogleClientId { get; set; } public virtual string GoogleClientSecret { get; set; } public virtual bool UseOAuth2Login { get; set; } public virtual string OAuth2ClientID { get; set; } public virtual string OAuth2ClientSecret { get; set; } public virtual string OAuth2LoginUrl { get; set; } public virtual string OAuth2TokenUrl { get; set; } public virtual string OAuth2DisplayName { get; set; } public virtual string OAuth2Scope { get; set; } public virtual bool IsClosingAgedTopics { get; set; } public virtual int CloseAgedTopicsDays { get; set; } public virtual bool IsPrivateForumInstance { get; set; } public virtual int PostImageMaxHeight { get; set; } public virtual int PostImageMaxWidth { get; set; } public virtual int PostImageMaxkBytes { get; set; } } ================================================ FILE: src/PopForums/Configuration/SettingsManager.cs ================================================ namespace PopForums.Configuration; public interface ISettingsManager { Settings Current { get; } void SaveCurrent(); void Save(Settings settings); } public class SettingsManager : ISettingsManager { public SettingsManager(ISettingsRepository settingsRepository, IErrorLog errorLog) { _settingsRepository = settingsRepository; _errorLog = errorLog; _settingsRepository.OnSettingsInvalidated += () => { _settings = null; }; } private readonly ISettingsRepository _settingsRepository; private readonly IErrorLog _errorLog; private Settings _settings; public Settings Current { get { if (_settings == null) LoadSettings(); return _settings; } } private void LoadSettings() { var dictionary = _settingsRepository.Get(); var settings = new Settings(); foreach (var setting in dictionary.Keys) { var property = settings.GetType().GetProperty(setting); if (property == null) { _errorLog.Log(null, ErrorSeverity.Warning, $"Settings repository returned a setting called {setting}, which does not exist in code."); } else { switch (property.PropertyType.FullName) { case "System.Boolean": property.SetValue(settings, Convert.ToBoolean(dictionary[setting]), null); break; case "System.String": property.SetValue(settings, dictionary[setting], null); break; case "System.Int32": property.SetValue(settings, Convert.ToInt32(dictionary[setting]), null); break; case "System.Double": property.SetValue(settings, Convert.ToDouble(dictionary[setting]), null); break; case "System.DateTime": property.SetValue(settings, Convert.ToDateTime(dictionary[setting]), null); break; default: throw new Exception($"Settings loader not coded to convert values of type {property.PropertyType.FullName}."); } } } _settings = settings; } public void Save(Settings settings) { _settings = settings; SaveCurrent(); } public void SaveCurrent() { var dictionary = new Dictionary(); var properties = Current.GetType().GetProperties(); foreach (var property in properties) { dictionary.Add(property.Name, property.GetValue(Current, null)); } _settingsRepository.Save(dictionary); } } ================================================ FILE: src/PopForums/Email/EmailQueuePayload.cs ================================================ namespace PopForums.Email; public class EmailQueuePayload { public int MessageID { get; set; } public EmailQueuePayloadType EmailQueuePayloadType { get; set; } public string ToEmail { get; set; } public string ToName { get; set; } public string TenantID { get; set; } } ================================================ FILE: src/PopForums/Email/EmailQueuePayloadType.cs ================================================ namespace PopForums.Email; public enum EmailQueuePayloadType { FullMessage = 1, MassMessage = 2, DeleteMassMessage = 3 } ================================================ FILE: src/PopForums/Email/EmailWorker.cs ================================================ namespace PopForums.Email; public interface IEmailWorker { Task Execute(); } public class EmailWorker(ISettingsManager settingsManager, ISmtpWrapper smtpWrapper, IQueuedEmailMessageRepository queuedEmailMessageRepository, IEmailQueueRepository emailQueueRepository, IErrorLog errorLog) : IEmailWorker { public async Task Execute() { try { var messageGroup = new List(); for (var i = 1; i <= settingsManager.Current.MailerQuantity; i++) { var payload = await emailQueueRepository.Dequeue(); if (payload == null) break; if (payload.EmailQueuePayloadType == EmailQueuePayloadType.DeleteMassMessage) throw new NotImplementedException($"EmailQueuePayloadType {payload.EmailQueuePayloadType} not implemented."); var queuedMessage = await queuedEmailMessageRepository.GetMessage(payload.MessageID); if (payload.EmailQueuePayloadType == EmailQueuePayloadType.MassMessage) { queuedMessage.ToEmail = payload.ToEmail; queuedMessage.ToName = payload.ToName; } if (queuedMessage == null) break; messageGroup.Add(queuedMessage); await queuedEmailMessageRepository.DeleteMessage(queuedMessage.MessageID); } Parallel.ForEach(messageGroup, message => { try { smtpWrapper.Send(message); } catch (Exception exc) { if (message == null) errorLog.Log(exc, ErrorSeverity.Email, "There was no message for the MailWorker to send."); else errorLog.Log(exc, ErrorSeverity.Email, $"MessageID: {message.MessageID}, To: <{message.ToEmail}> {message.ToName}, Subject: {message.Subject}"); } }); } catch (Exception exc) { errorLog.Log(exc, ErrorSeverity.Error); } } } ================================================ FILE: src/PopForums/Email/ForgotPasswordMailer.cs ================================================ namespace PopForums.Email; public interface IForgotPasswordMailer { Task ComposeAndQueue(User user, string resetLink); } public class ForgotPasswordMailer : IForgotPasswordMailer { public ForgotPasswordMailer(ISettingsManager settingsManager, IQueuedEmailService queuedEmailService) { _settingsManager = settingsManager; _queuedEmailService = queuedEmailService; } private readonly ISettingsManager _settingsManager; private readonly IQueuedEmailService _queuedEmailService; public async Task ComposeAndQueue(User user, string resetLink) { if (user == null) throw new ArgumentNullException("user"); if (String.IsNullOrEmpty(resetLink)) throw new ArgumentException("resetLink"); var settings = _settingsManager.Current; var body = String.Format(Resources.ForgotPasswordEmail , settings.ForumTitle, resetLink, settings.MailSignature, Environment.NewLine); var message = new QueuedEmailMessage { Body = body, Subject = String.Format(Resources.ForgotPasswordSubject, settings.ForumTitle), ToEmail = user.Email, ToName = user.Name, FromName = settings.ForumTitle, QueueTime = DateTime.UtcNow }; await _queuedEmailService.CreateAndQueueEmail(message); } } ================================================ FILE: src/PopForums/Email/MailingListComposer.cs ================================================ namespace PopForums.Email; public interface IMailingListComposer { void ComposeAndQueue(User user, string subject, string body, string htmlBody, string unsubscribeLink); } public class MailingListComposer : IMailingListComposer { public MailingListComposer(ISettingsManager settingsManager, IQueuedEmailService queuedEmailService) { _settingsManager = settingsManager; _queuedEmailService = queuedEmailService; } private readonly ISettingsManager _settingsManager; private readonly IQueuedEmailService _queuedEmailService; public void ComposeAndQueue(User user, string subject, string body, string htmlBody, string unsubscribeLink) { var settings = _settingsManager.Current; var ps = $"{Environment.NewLine}{Environment.NewLine}Unsubscribe: {unsubscribeLink}"; var message = new QueuedEmailMessage { Body = body + ps, Subject = subject, ToEmail = user.Email, ToName = user.Name, FromName = settings.ForumTitle, QueueTime = DateTime.UtcNow }; if (!string.IsNullOrWhiteSpace(htmlBody)) message.HtmlBody = $"{htmlBody}

Unsubscribe: {unsubscribeLink}

"; _queuedEmailService.CreateAndQueueEmail(message); } } ================================================ FILE: src/PopForums/Email/NewAccountMailer.cs ================================================ namespace PopForums.Email; public interface INewAccountMailer { SmtpStatusCode? Send(User user, string verifyUrl); /// /// Used to deliver the text for a welcome e-mail, where the user is already /// approved. The default implementation uses Resources.RegisterEmailThankYou. /// It uses the following string format items: /// {0} Forum title (from settings) /// {1} Mail signature (from settings) /// {2} Environment.NewLine /// string NewUserApprovedEmail { get; } /// /// Used to deliver the text for a welcome e-mail, where the user is must follow /// a verification link. The default implementation uses Resources.RegisterEmailThankYou. /// It uses the following string format items: /// {0} Forum title (from settings) /// {1} Verification URL + auth code (generated by calling code) /// {2} Verification URL /// {3} Authorization key (from user object) /// {4} Mail signature (from settings) /// {5} Environment.NewLine /// string NewUserVerifyEmail { get; } } public class NewAccountMailer : INewAccountMailer { public NewAccountMailer(ISettingsManager settingsManager, ISmtpWrapper smtpWrapper) { _settingsManager = settingsManager; _smtpWrapper = smtpWrapper; } private readonly ISettingsManager _settingsManager; private readonly ISmtpWrapper _smtpWrapper; public SmtpStatusCode? Send(User user, string verifyUrl) { var settings = _settingsManager.Current; if (String.IsNullOrWhiteSpace(settings.MailerAddress)) throw new Exception("There is no MailerAddress to send e-mail from. Perhaps you didn't set up the settings."); var message = new EmailMessage { ToEmail = user.Email, ToName = user.Name, FromName = settings.ForumTitle }; message.Subject = String.Format(Resources.RegisterEmailSubject, settings.ForumTitle); string body; if (settings.IsNewUserApproved) body = String.Format(NewUserApprovedEmail, settings.ForumTitle, settings.MailSignature, "\r\n"); else body = String.Format(NewUserVerifyEmail, settings.ForumTitle, verifyUrl + "/" + user.AuthorizationKey, verifyUrl, user.AuthorizationKey, settings.MailSignature, "\r\n"); message.Body = body; return _smtpWrapper.Send(message); } public virtual string NewUserApprovedEmail { get { return Resources.RegisterEmailThankYou; } } public virtual string NewUserVerifyEmail { get { return Resources.RegisterEmailThankYouVerify; } } } ================================================ FILE: src/PopForums/Email/SmtpStatusCode.cs ================================================ namespace PopForums.Email; public enum SmtpStatusCode { SystemStatus = 211, HelpMessage = 214, ServiceReady = 220, ServiceClosingTransmissionChannel = 221, AuthenticationSuccessful = 235, Ok = 250, UserNotLocalWillForward = 251, CannotVerifyUserWillAttemptDelivery = 252, AuthenticationChallenge = 334, StartMailInput = 354, ServiceNotAvailable = 421, PasswordTransitionNeeded = 432, MailboxBusy = 450, ErrorInProcessing = 451, InsufficientStorage = 452, TemporaryAuthenticationFailure = 454, CommandUnrecognized = 500, SyntaxError = 501, CommandNotImplemented = 502, BadCommandSequence = 503, CommandParameterNotImplemented = 504, AuthenticationRequired = 530, AuthenticationMechanismTooWeak = 534, AuthenticationInvalidCredentials = 535, EncryptionRequiredForAuthenticationMechanism = 538, MailboxUnavailable = 550, UserNotLocalTryAlternatePath = 551, ExceededStorageAllocation = 552, MailboxNameNotAllowed = 553, TransactionFailed = 554, MailFromOrRcptToParametersNotRecognizedOrNotImplemented = 555 } ================================================ FILE: src/PopForums/Email/SmtpWrapper.cs ================================================ using MailKit.Net.Smtp; using MailKit.Security; using MimeKit; namespace PopForums.Email; public interface ISmtpWrapper { SmtpStatusCode? Send(EmailMessage message); } public class SmtpWrapper : ISmtpWrapper { public SmtpWrapper(ISettingsManager settingsManager, IErrorLog errorLog) { _settingsManager = settingsManager; _errorLog = errorLog; } private readonly ISettingsManager _settingsManager; private readonly IErrorLog _errorLog; public SmtpStatusCode? Send(EmailMessage message) { if (message == null) throw new ArgumentNullException(nameof(message)); var parsedMessage = ConvertEmailMessage(message); var settings = _settingsManager.Current; SmtpStatusCode? result = SmtpStatusCode.Ok; using var client = new SmtpClient(); try { client.Connect(settings.SmtpServer, settings.SmtpPort, settings.UseSslSmtp ? SecureSocketOptions.StartTls : SecureSocketOptions.None); if (settings.UseEsmtp) client.Authenticate(settings.SmtpUser, settings.SmtpPassword); client.Send(parsedMessage); } catch (SmtpCommandException exc) { var statusCode = (int)exc.StatusCode; result = (SmtpStatusCode)statusCode; _errorLog.Log(exc, ErrorSeverity.Email, $"To: {message.ToEmail}, Subject: {message.Subject}, SmtpCommandException: {statusCode}"); } catch (SmtpProtocolException exc) { result = null; _errorLog.Log(exc, ErrorSeverity.Email, $"To: {message.ToEmail}, Subject: {message.Subject}, SmtpProtocolException: {exc.Message}"); } catch (Exception exc) { result = null; _errorLog.Log(exc, ErrorSeverity.Email, $"To: {message.ToEmail}, Subject: {message.Subject}, Exception: {exc.Message}"); } finally { client.Disconnect(true); } return result; } private MimeMessage ConvertEmailMessage(EmailMessage forumMessage) { var message = new MimeMessage(); message.Headers.Add("X-Mailer", "POP Forums"); message.To.Add(new MailboxAddress(forumMessage.ToName, forumMessage.ToEmail)); message.From.Add(new MailboxAddress(forumMessage.FromName, _settingsManager.Current.MailerAddress)); if (!string.IsNullOrWhiteSpace(forumMessage.ReplyTo)) message.ReplyTo.Add(new MailboxAddress(forumMessage.FromName, forumMessage.ReplyTo)); else message.ReplyTo.Add(new MailboxAddress(forumMessage.FromName, _settingsManager.Current.ReplyToAddress)); message.Subject = forumMessage.Subject; var builder = new BodyBuilder(); builder.TextBody = forumMessage.Body; builder.HtmlBody = forumMessage.HtmlBody; message.Body = builder.ToMessageBody(); return message; } } ================================================ FILE: src/PopForums/Extensions/ServiceCollections.cs ================================================ namespace PopForums.Extensions; public static class ServiceCollections { public static void AddPopForumsBase(this IServiceCollection services) { // config services.AddTransient(); services.AddTransient(); services.AddSingleton(); services.AddTransient(); // composers services.AddTransient(); services.AddTransient(); services.AddTransient(); // email services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); // external auth? services.AddTransient(); // feeds services.AddTransient(); // scoring game services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); // services services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); } } ================================================ FILE: src/PopForums/Extensions/Streams.cs ================================================ namespace PopForums.Extensions; public static class Streams { public static byte[] ToBytes(this Stream stream) { var length = (int)stream.Length; var bytes = new byte[length]; stream.ReadExactly(bytes, 0, length); return bytes; } } ================================================ FILE: src/PopForums/Extensions/Strings.cs ================================================ namespace PopForums.Extensions; public static class Strings { public static string GetSHA256Hash(this string text) { if (string.IsNullOrWhiteSpace(text)) { return string.Empty; } var input = Encoding.UTF8.GetBytes(text); using (var sha256 = SHA256.Create()) { var output = sha256.ComputeHash(input); return Convert.ToBase64String(output); } } public static string GetSHA256Hash(this string text, Guid salt) { var concatString = text + salt; return GetSHA256Hash(concatString); } public static string GetMD5Hash(this string text) { if (string.IsNullOrWhiteSpace(text)) { return string.Empty; } var input = Encoding.UTF8.GetBytes(text); using (var md5 = MD5.Create()) { var output = md5.ComputeHash(input); return Convert.ToBase64String(output); } } public static string GetMD5Hash(this string text, Guid salt) { var concatString = text + salt; return GetMD5Hash(concatString); } public static bool IsEmailAddress(this string text) { return Regex.IsMatch(text, @"^\S+?@([a-z0-9\-\.])+?\.([a-z0-9\-\.])+$", RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture); } public static string ToUrlName(this string text) { if (text == null) throw new Exception("Can't Url convert a null string."); var result = text.Replace(" ", "-"); var replacer = new Regex(@"[^\w\-]", RegexOptions.None); result = replacer.Replace(result, "").ToLower(); return result; } public static string ToUniqueUrlName(this string name, List matchingStartsWith) { var urlName = name.ToUrlName(); return ToUniqueName(urlName, matchingStartsWith); } public static string ToUniqueName(this string name, List matchingStartsWith) { name = Regex.Escape(name).Replace("\\ ", " "); var originalName = name; var matchTest = name.Replace("-", @"\-"); var count = matchingStartsWith.Count(m => Regex.IsMatch(m, @"^(" + matchTest + @")(\-\d)?$")); if (count > 0) name = name + "-" + (count + 1); while (matchingStartsWith.Exists(x => x == name)) { count++; name = originalName + "-" + (count + 1); } return name; } public static string Trimmer(this string stringToTrim, int maxLength) { if (maxLength < 20) maxLength = 20; if (stringToTrim.Length <= maxLength) return stringToTrim; return stringToTrim.Substring(0, maxLength - 13) + "..." + stringToTrim.Substring(stringToTrim.Length - 10, 10); } } ================================================ FILE: src/PopForums/Extensions/Users.cs ================================================ namespace PopForums.Extensions; public static class Users { public static bool IsPostEditable(this User user, Post post) { if (user == null) return false; return user.IsInRole(PermanentRoles.Moderator) || user.UserID == post.UserID; } } ================================================ FILE: src/PopForums/ExternalLogin/ExternalAuthenticationResult.cs ================================================ namespace PopForums.ExternalLogin; public class ExternalAuthenticationResult { public string Issuer { get; set; } public string ProviderKey { get; set; } public string Name { get; set; } public string Email { get; set; } } ================================================ FILE: src/PopForums/ExternalLogin/ExternalLoginInfo.cs ================================================ namespace PopForums.ExternalLogin; public class ExternalLoginInfo { public ExternalLoginInfo(string loginProvider, string providerKey, string displayName) { LoginProvider = loginProvider; ProviderKey = providerKey; ProviderDisplayName = displayName; } public string LoginProvider { get; set; } public string ProviderKey { get; set; } public string ProviderDisplayName { get; set; } } ================================================ FILE: src/PopForums/ExternalLogin/ExternalUserAssociation.cs ================================================ namespace PopForums.ExternalLogin; public class ExternalUserAssociation { public int ExternalUserAssociationID { get; set; } public int UserID { get; set; } public string Issuer { get; set; } public string ProviderKey { get; set; } public string Name { get; set; } } ================================================ FILE: src/PopForums/ExternalLogin/ExternalUserAssociationManager.cs ================================================ namespace PopForums.ExternalLogin; public interface IExternalUserAssociationManager { Task ExternalUserAssociationCheck(ExternalLoginInfo externalLoginInfo, string ip); Task Associate(User user, ExternalLoginInfo externalLoginInfo, string ip); Task> GetExternalUserAssociations(User user); Task RemoveAssociation(User user, int externalUserAssociationID, string ip); } public class ExternalUserAssociationManager : IExternalUserAssociationManager { public ExternalUserAssociationManager(IExternalUserAssociationRepository externalUserAssociationRepository, IUserRepository userRepository, ISecurityLogService securityLogService) { _externalUserAssociationRepository = externalUserAssociationRepository; _userRepository = userRepository; _securityLogService = securityLogService; } private readonly IExternalUserAssociationRepository _externalUserAssociationRepository; private readonly IUserRepository _userRepository; private readonly ISecurityLogService _securityLogService; public async Task ExternalUserAssociationCheck(ExternalLoginInfo externalLoginInfo, string ip) { if (externalLoginInfo == null) throw new ArgumentNullException(nameof(externalLoginInfo)); var match = await _externalUserAssociationRepository.Get(externalLoginInfo.LoginProvider, externalLoginInfo.ProviderKey); if (match == null) { await _securityLogService.CreateLogEntry((int?)null, null, ip, $"Issuer: {externalLoginInfo.LoginProvider}, Provider: {externalLoginInfo.ProviderKey}, Name: {externalLoginInfo.ProviderDisplayName}", SecurityLogType.ExternalAssociationCheckFailed); return new ExternalUserAssociationMatchResult {Successful = false}; } var user = await _userRepository.GetUser(match.UserID); if (user == null) { await _securityLogService.CreateLogEntry((int?)null, null, ip, $"Issuer: {externalLoginInfo.LoginProvider}, Provider: {externalLoginInfo.ProviderKey}, Name: {externalLoginInfo.ProviderDisplayName}", SecurityLogType.ExternalAssociationCheckFailed); return new ExternalUserAssociationMatchResult {Successful = false}; } var result = new ExternalUserAssociationMatchResult { Successful = true, ExternalUserAssociation = match, User = user }; await _securityLogService.CreateLogEntry(user, user, ip, $"Issuer: {match.Issuer}, Provider: {match.ProviderKey}, Name: {match.Name}", SecurityLogType.ExternalAssociationCheckSuccessful); return result; } public async Task Associate(User user, ExternalLoginInfo externalLoginInfo, string ip) { if (user == null) throw new ArgumentNullException(nameof(user)); if (externalLoginInfo != null) { if (string.IsNullOrEmpty(externalLoginInfo.LoginProvider)) throw new NullReferenceException("The external login info contains no provider."); if (string.IsNullOrEmpty(externalLoginInfo.ProviderKey)) throw new NullReferenceException("The external login info contains no provider key."); if (string.IsNullOrEmpty(externalLoginInfo.ProviderDisplayName)) externalLoginInfo.ProviderDisplayName = string.Empty; await _externalUserAssociationRepository.Save(user.UserID, externalLoginInfo.LoginProvider, externalLoginInfo.ProviderKey, externalLoginInfo.ProviderDisplayName); await _securityLogService.CreateLogEntry(user, user, ip, $"Provider: {externalLoginInfo.LoginProvider}, DisplayName: {externalLoginInfo.ProviderDisplayName}", SecurityLogType.ExternalAssociationSet); } } public async Task> GetExternalUserAssociations(User user) { return await _externalUserAssociationRepository.GetByUser(user.UserID); } public async Task RemoveAssociation(User user, int externalUserAssociationID, string ip) { var association = await _externalUserAssociationRepository.Get(externalUserAssociationID); if (association == null) return; if (association.UserID != user.UserID) throw new Exception($"Can't delete external user association {externalUserAssociationID} because it doesn't match UserID {user.UserID}."); await _externalUserAssociationRepository.Delete(externalUserAssociationID); await _securityLogService.CreateLogEntry(user, user, ip, $"Issuer: {association.Issuer}, Provider: {association.ProviderKey}, Name: {association.Name}", SecurityLogType.ExternalAssociationRemoved); } } ================================================ FILE: src/PopForums/ExternalLogin/ExternalUserAssociationMatchResult.cs ================================================ namespace PopForums.ExternalLogin; public class ExternalUserAssociationMatchResult { public bool Successful { get; set; } public ExternalUserAssociation ExternalUserAssociation { get; set; } public User User { get; set; } } ================================================ FILE: src/PopForums/Feeds/FeedService.cs ================================================ namespace PopForums.Feeds; public interface IFeedService { Task PublishToFeed(User user, string message, int points, DateTime timeStamp); Task> GetFeed(User user); } public class FeedService : IFeedService { public FeedService(IFeedRepository feedRepository, IBroker broker) { _feedRepository = feedRepository; _broker = broker; } private readonly IFeedRepository _feedRepository; private readonly IBroker _broker; public const int MaxFeedCount = 50; public async Task PublishToFeed(User user, string message, int points, DateTime timeStamp) { if (user == null) return; await _feedRepository.PublishEvent(user.UserID, message, points, timeStamp); var cutOff = await _feedRepository.GetOldestTime(user.UserID, MaxFeedCount); await _feedRepository.DeleteOlderThan(user.UserID, cutOff); } public async Task> GetFeed(User user) { return await _feedRepository.GetFeed(user.UserID, MaxFeedCount); } } ================================================ FILE: src/PopForums/Global.cs ================================================ global using System; global using System.Collections; global using System.Collections.Generic; global using System.IO; global using System.Linq; global using System.Net.Http; global using System.Security; global using System.Security.Cryptography; global using System.Text; global using System.Text.Encodings.Web; global using System.Text.Json; global using System.Text.Json.Serialization; global using System.Text.RegularExpressions; global using System.Threading; global using System.Threading.Tasks; global using Microsoft.Extensions.Configuration; global using Microsoft.Extensions.DependencyInjection; global using PopForums.Composers; global using PopForums.Configuration; global using PopForums.Email; global using PopForums.Extensions; global using PopForums.ExternalLogin; global using PopForums.Feeds; global using PopForums.Messaging; global using PopForums.Models; global using PopForums.Repositories; global using PopForums.ScoringGame; global using PopForums.Services; ================================================ FILE: src/PopForums/Messaging/IBroker.cs ================================================ namespace PopForums.Messaging; public interface IBroker { void NotifyNewPosts(Topic topic, int lasPostID); void NotifyForumUpdate(Forum forum); void NotifyTopicUpdate(Topic topic, Forum forum, string topicLink); void NotifyNewPost(Topic topic, int postID); void NotifyPMCount(int userID, int pmCount); void NotifyUser(Notification notification); void NotifyUser(Notification notification, string tenantID); void SendPMMessage(PrivateMessagePost post); } ================================================ FILE: src/PopForums/Messaging/Models/AwardData.cs ================================================ namespace PopForums.Messaging.Models; public class AwardData { public string Title { get; set; } } ================================================ FILE: src/PopForums/Messaging/Models/AwardPayload.cs ================================================ namespace PopForums.Messaging.Models; public class AwardPayload { public string Title { get; set; } public int UserID { get; set; } public string TenantID { get; set; } } ================================================ FILE: src/PopForums/Messaging/Models/QuestionData.cs ================================================ namespace PopForums.Messaging.Models; public class QuestionData { public string AskerName { get; set; } public string Title { get; set; } public int PostID { get; set; } } ================================================ FILE: src/PopForums/Messaging/Models/ReplyData.cs ================================================ namespace PopForums.Messaging.Models; public class ReplyData { public string PostName { get; set; } public int TopicID { get; set; } public string Title { get; set; } } ================================================ FILE: src/PopForums/Messaging/Models/ReplyPayload.cs ================================================ namespace PopForums.Messaging.Models; public class ReplyPayload { public string PostName { get; set; } public string Title { get; set; } public int TopicID { get; set; } public int UserID { get; set; } public string TenantID { get; set; } } ================================================ FILE: src/PopForums/Messaging/Models/VoteData.cs ================================================ namespace PopForums.Messaging.Models; public class VoteData { public string VoterName { get; set; } public string Title { get; set; } public int PostID { get; set; } } ================================================ FILE: src/PopForums/Messaging/Notification.cs ================================================ namespace PopForums.Messaging; public class Notification { public int UserID { get; set; } public DateTime TimeStamp { get; set; } public bool IsRead { get; set; } public NotificationType NotificationType { get; set; } public long ContextID { get; set; } public JsonElement Data { get; set; } public int UnreadCount { get; set; } } ================================================ FILE: src/PopForums/Messaging/NotificationAdapter.cs ================================================ using PopForums.Messaging.Models; namespace PopForums.Messaging; public interface INotificationAdapter { Task Reply(string postName, string title, int topicID, int userID, string tenantID); Task Vote(string voterName, string title, int postID, int userID); Task QuestionAnswer(string askerName, string title, int postID, int userID); Task Award(string title, int userID); Task Award(string title, int userID, string tenantID); } public class NotificationAdapter : INotificationAdapter { private readonly INotificationManager _notificationManager; public NotificationAdapter(INotificationManager notificationManager) { _notificationManager = notificationManager; } public async Task Reply(string postName, string title, int topicID, int userID, string tenantID) { var replyData = new ReplyData { PostName = postName, Title = title, TopicID = topicID }; await _notificationManager.ProcessNotification(userID, NotificationType.NewReply, replyData.TopicID, replyData, tenantID); } public async Task Vote(string voterName, string title, int postID, int userID) { var voteData = new VoteData { VoterName = voterName, Title = title, PostID = postID }; await _notificationManager.ProcessNotification(userID, NotificationType.VoteUp, postID, voteData); } public async Task QuestionAnswer(string askerName, string title, int postID, int userID) { var questionData = new QuestionData { AskerName = askerName, Title = title, PostID = postID }; await _notificationManager.ProcessNotification(userID, NotificationType.QuestionAnswered, postID, questionData); } public async Task Award(string title, int userID) { await Award(title, userID, null); } public async Task Award(string title, int userID, string tenantID) { var awardData = new AwardData { Title = title }; var sequentialContext = DateTime.UtcNow.Ticks; await _notificationManager.ProcessNotification(userID, NotificationType.Award, sequentialContext, awardData, tenantID); } } ================================================ FILE: src/PopForums/Messaging/NotificationManager.cs ================================================ namespace PopForums.Messaging; public interface INotificationManager { Task MarkNotificationRead(int userID, NotificationType notificationType, long contextID); Task ProcessNotification(int userID, NotificationType notificationType, long contextID, dynamic data); Task ProcessNotification(int userID, NotificationType notificationType, long contextID, dynamic data, string tenantID); Task GetUnreadNotificationCount(int userID); Task MarkAllRead(int userID); Task> GetNotifications(int userID, DateTime afterDateTime); } public class NotificationManager : INotificationManager { private readonly INotificationRepository _notificationRepository; private readonly IBroker _broker; private const int PageSize = 20; private const int MaxNotificationCount = 100; public NotificationManager(INotificationRepository notificationRepository, IBroker broker) { _notificationRepository = notificationRepository; _broker = broker; } public async Task ProcessNotification(int userID, NotificationType notificationType, long contextID, dynamic data) { await ProcessNotification(userID, notificationType, contextID, data, null); } public async Task ProcessNotification(int userID, NotificationType notificationType, long contextID, dynamic data, string tenantID) { var serializedData = JsonSerializer.SerializeToElement(data, new JsonSerializerOptions{ PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); var notification = new Notification { UserID = userID, TimeStamp = DateTime.UtcNow, IsRead = false, NotificationType = notificationType, ContextID = contextID, Data = serializedData }; var recordsUpdated = await _notificationRepository.UpdateNotification(notification); if (recordsUpdated == 0) await _notificationRepository.CreateNotification(notification); notification.UnreadCount = await _notificationRepository.GetUnreadNotificationCount(userID); if (tenantID == null || string.IsNullOrWhiteSpace(tenantID)) _broker.NotifyUser(notification); else _broker.NotifyUser(notification, tenantID); } public async Task MarkNotificationRead(int userID, NotificationType notificationType, long contextID) { await _notificationRepository.MarkNotificationRead(userID, notificationType, contextID); } public async Task> GetNotifications(int userID, DateTime afterDateTime) { return await _notificationRepository.GetNotifications(userID, afterDateTime, PageSize); } public async Task GetUnreadNotificationCount(int userID) { var count = await _notificationRepository.GetUnreadNotificationCount(userID); return count > MaxNotificationCount ? MaxNotificationCount : count; } public async Task MarkAllRead(int userID) { await _notificationRepository.MarkAllRead(userID); } } ================================================ FILE: src/PopForums/Messaging/NotificationTunnel.cs ================================================ namespace PopForums.Messaging; public interface INotificationTunnel { void SendNotificationForUserAward(string title, int userID, string tenantID); void SendNotificationForReply(string postName, string title, int topicID, int userID, string tenantID); } public class NotificationTunnel : INotificationTunnel { public static string HeaderName = "PfApi"; private readonly INotificationAdapter _notificationAdapter; public NotificationTunnel(INotificationAdapter notificationAdapter) { _notificationAdapter = notificationAdapter; } public async void SendNotificationForUserAward(string title, int userID, string tenantID) { await _notificationAdapter.Award(title, userID, tenantID); } public async void SendNotificationForReply(string postName, string title, int topicID, int userID, string tenantID) { await _notificationAdapter.Reply(postName, title, topicID, userID, tenantID); } } ================================================ FILE: src/PopForums/Messaging/NotificationType.cs ================================================ namespace PopForums.Messaging; public enum NotificationType { NewReply = 0, VoteUp = 1, QuestionAnswered = 2, Award = 3 } ================================================ FILE: src/PopForums/Models/AwardCalculationPayload.cs ================================================ namespace PopForums.Models; public class AwardCalculationPayload { public string EventDefinitionID { get; set; } public int UserID { get; set; } public string TenantID { get; set; } } ================================================ FILE: src/PopForums/Models/BasicJsonMessage.cs ================================================ namespace PopForums.Models; public class BasicJsonMessage { public bool Result { get; set; } public string Message { get; set; } public object Data { get; set; } public string Redirect { get; set; } } ================================================ FILE: src/PopForums/Models/BasicServiceResponse.cs ================================================ namespace PopForums.Models; public class BasicServiceResponse where T : class { public bool IsSuccessful { get; set; } public string Message { get; set; } public T Data { get; set; } public string Redirect { get; set; } } ================================================ FILE: src/PopForums/Models/CategorizedForumContainer.cs ================================================ namespace PopForums.Models; public class CategorizedForumContainer { public CategorizedForumContainer(IEnumerable categories, IEnumerable forums) { ReadStatusLookup = new Dictionary(); AllCategories = categories; AllForums = forums; UncategorizedForums = forums.Where(f => !f.CategoryID.HasValue || f.CategoryID == 0).OrderBy(f => f.SortOrder).ToList(); CategoryDictionary = new Dictionary>(); foreach (var category in AllCategories.OrderBy(c => c.SortOrder)) { var forumSet = AllForums.Where(f => f.CategoryID == category.CategoryID).OrderBy(f => f.SortOrder).ToList(); if (forumSet.Count > 0) CategoryDictionary.Add(category, forumSet); } } public IEnumerable AllCategories { get; private set; } public IEnumerable AllForums { get; private set; } public List UncategorizedForums { get; private set; } public Dictionary> CategoryDictionary { get; private set; } public string ForumTitle { get; set; } public Dictionary ReadStatusLookup { get; private set; } } ================================================ FILE: src/PopForums/Models/Category.cs ================================================ namespace PopForums.Models; public class Category { public int CategoryID { get; set; } public string Title { get; set; } public int SortOrder { get; set; } } ================================================ FILE: src/PopForums/Models/CategoryContainerWithForums.cs ================================================ namespace PopForums.Models; public class CategoryContainerWithForums { public Category Category { get; set; } public IEnumerable Forums { get; set; } } ================================================ FILE: src/PopForums/Models/ClientPrivateMessagePost.cs ================================================ namespace PopForums.Models; public class ClientPrivateMessagePost { public int PMPostID { get; set; } public int UserID { get; set; } public string Name { get; set; } public string PostTime { get; set; } public string FullText { get; set; } public static ClientPrivateMessagePost[] MapForClient(List posts) { var messages = posts.Select(x => new ClientPrivateMessagePost { PMPostID = x.PMPostID, UserID = x.UserID, Name = x.Name, PostTime = x.PostTime.ToString("o"), FullText = x.FullText }).ToArray(); return messages; } public static ClientPrivateMessagePost MapForClient(PrivateMessagePost post) { var message = new ClientPrivateMessagePost { PMPostID = post.PMPostID, UserID = post.UserID, Name = post.Name, PostTime = post.PostTime.ToString("o"), FullText = post.FullText }; return message; } } ================================================ FILE: src/PopForums/Models/DisplayProfile.cs ================================================ namespace PopForums.Models; public class DisplayProfile { public DisplayProfile(User user, Profile profile, UserImage userImage) { UserID = user.UserID; Name = user.Name; Joined = user.CreationDate; Dob = profile.Dob; Location = profile.Location; Web = profile.Web; Instagram = profile.Instagram; Facebook = profile.Facebook; AvatarID = profile.AvatarID; ImageID = profile.ImageID; ShowDetails = profile.ShowDetails; if (userImage != null && userImage.IsApproved) IsImageApproved = true; Points = profile.Points; IsApproved = user.IsApproved; } public int UserID { get; set; } public string Name { get; set; } public DateTime Joined { get; set; } public DateTime? Dob { get; set; } public string Location { get; set; } public int PostCount { get; set; } public string Web { get; set; } public string Instagram { get; set; } public string Facebook { get; set; } public int? AvatarID { get; set; } public int? ImageID { get; set; } public bool ShowDetails { get; set; } public bool IsImageApproved { get; set; } public int Points { get; set; } public List Feed { get; set; } public List UserAwards { get; set; } public bool IsApproved { get; set; } } ================================================ FILE: src/PopForums/Models/EmailMessage.cs ================================================ namespace PopForums.Models; public class EmailMessage { public string FromName { get; set; } public string ToEmail { get; set; } public string ToName { get; set; } public string Subject { get; set; } public string Body { get; set; } public string HtmlBody { get; set; } public string ReplyTo { get; set; } } ================================================ FILE: src/PopForums/Models/ErrorLogEntry.cs ================================================ namespace PopForums.Models; public class ErrorLogEntry { public int ErrorID { get; set; } public DateTime TimeStamp { get; set; } public string Message { get; set; } public string StackTrace { get; set; } public string Data { get; set; } public ErrorSeverity Severity { get; set; } public string SeverityString => Severity.ToString(); } ================================================ FILE: src/PopForums/Models/ExpiredUserSession.cs ================================================ namespace PopForums.Models; public class ExpiredUserSession { public int SessionID { get; set; } public int? UserID { get; set; } public DateTime LastTime { get; set; } } ================================================ FILE: src/PopForums/Models/FeedEvent.cs ================================================ namespace PopForums.Models; public class FeedEvent { public int UserID { get; set; } public string Message { get; set; } public int Points { get; set; } public DateTime TimeStamp { get; set; } } ================================================ FILE: src/PopForums/Models/Forum.cs ================================================ namespace PopForums.Models; public class Forum { public int ForumID { get; set; } public int? CategoryID { get; set; } public string Title { get; set; } public string Description { get; set; } public bool IsVisible { get; set; } public bool IsArchived { get; set; } public int SortOrder { get; set; } public int TopicCount { get; set; } public int PostCount { get; set; } public DateTime LastPostTime { get; set; } public string LastPostName { get; set; } public string UrlName { get; set; } public string ForumAdapterName { get; set; } public bool IsQAForum { get; set; } } ================================================ FILE: src/PopForums/Models/ForumPermissionContainer.cs ================================================ namespace PopForums.Models; public class ForumPermissionContainer { public int ForumID { get; set; } public List AllRoles { get; set; } public List PostRoles { get; set; } public List ViewRoles { get; set; } } ================================================ FILE: src/PopForums/Models/ForumPermissionContext.cs ================================================ namespace PopForums.Models; public class ForumPermissionContext { public bool UserCanView { get; set; } public bool UserCanPost { get; set; } public bool UserCanModerate { get; set; } public string DenialReason { get; set; } } ================================================ FILE: src/PopForums/Models/ForumState.cs ================================================ namespace PopForums.Models; public class ForumState { public int? ForumID { get; set; } public int PageSize { get; set; } public int PageIndex { get; set; } } ================================================ FILE: src/PopForums/Models/ForumTopicContainer.cs ================================================ namespace PopForums.Models; public class ForumTopicContainer : PagedTopicContainer { public Forum Forum { get; set; } public ForumPermissionContext PermissionContext { get; set; } public ForumState ForumState { get; set; } } ================================================ FILE: src/PopForums/Models/IPHistoryEvent.cs ================================================ namespace PopForums.Models; public class IPHistoryEvent { public DateTime EventTime { get; set; } public string Type { get; set; } public string Description { get; set; } public int? UserID { get; set; } public string Name { get; set; } public object ID { get; set; } } ================================================ FILE: src/PopForums/Models/IStreamResponse.cs ================================================ namespace PopForums.Models; /// /// This interface is used to pass a Stream to send to the client. It implements IDisposable to allow for cleanup /// of the Stream and any other unmanaged resources (like the database connections, for example). Because using /// statements would fall out of scope when a controller action returns, the Stream would be disposed before it /// could be used. This interface allows for the Stream to be used and then disposed of when the client is done by /// registering it with the HttpResponse RegisterForDispose method. /// public interface IStreamResponse : IDisposable { Stream Stream { get; } } ================================================ FILE: src/PopForums/Models/Ignore.cs ================================================ namespace PopForums.Models; public class Ignore { public int UserID { get; set; } public int IgnoreUserID { get; set; } } public class IgnoreWithName : Ignore { public string Name { get; set; } } ================================================ FILE: src/PopForums/Models/ModerationLogEntry.cs ================================================ namespace PopForums.Models; public class ModerationLogEntry { public int ModerationID { get; set; } public DateTime TimeStamp { get; set; } public int UserID { get; set; } public string UserName { get; set; } public ModerationType ModerationType { get; set; } public string ModerationTypeString => ModerationType.ToString(); public int? ForumID { get; set; } public int TopicID { get; set; } public int? PostID { get; set; } public string Comment { get; set; } public string OldText { get; set; } } ================================================ FILE: src/PopForums/Models/ModerationType.cs ================================================ namespace PopForums.Models; public enum ModerationType { NotSet = 0, PostEdit = 1, PostDelete = 2, PostDeletePermanently = 3, TopicEdit = 4, TopicDelete = 5, TopicDeletePermanently = 6, TopicClose = 7, TopicOpen = 8, TopicPin = 9, TopicUnpin = 10, TopicMoved = 11, TopicUndelete = 12, PostUndelete = 13, TopicRenamed = 14, TopicCloseAuto = 15 } ================================================ FILE: src/PopForums/Models/ModifyForumRolesContainer.cs ================================================ namespace PopForums.Models; public class ModifyForumRolesContainer { public int ForumID { get; set; } public ModifyForumRolesType ModifyType { get; set; } public string Role { get; set; } } ================================================ FILE: src/PopForums/Models/ModifyForumRolesType.cs ================================================ namespace PopForums.Models; public enum ModifyForumRolesType { AddView, RemoveView, AddPost, RemovePost, RemoveAllView, RemoveAllPost } ================================================ FILE: src/PopForums/Models/NewPost.cs ================================================ namespace PopForums.Models; public class NewPost { public string Title { get; set; } public string FullText { get; set; } public bool IncludeSignature { get; set; } /// /// The ForumID or TopicID the post will be associated with. /// public int ItemID { get; set; } public bool CloseOnReply { get; set; } public bool IsPlainText { get; set; } public bool IsImageEnabled { get; set; } public int ParentPostID { get; set; } public string[] PostImageIDs { get; set; } } ================================================ FILE: src/PopForums/Models/PagedListOfT.cs ================================================ namespace PopForums.Models; public class PagedList : PagerContext where T : class { public IEnumerable List { get; set; } } ================================================ FILE: src/PopForums/Models/PagedTopicContainer.cs ================================================ namespace PopForums.Models; public class PagedTopicContainer { public PagedTopicContainer() { ReadStatusLookup = new Dictionary(); } public List Topics { get; set; } public PagerContext PagerContext { get; set; } public Dictionary ReadStatusLookup { get; private set; } public Dictionary ForumTitles { get; set; } } ================================================ FILE: src/PopForums/Models/PagerContext.cs ================================================ namespace PopForums.Models; public class PagerContext { public int PageCount { get; set; } public int PageIndex { get; set; } public int PageSize { get; set; } } ================================================ FILE: src/PopForums/Models/PasswordResetContainer.cs ================================================ namespace PopForums.Models; public class PasswordResetContainer { public bool IsValidUser { get; set; } public string Password { get; set; } public string PasswordRetype { get; set; } public string Result { get; set; } } ================================================ FILE: src/PopForums/Models/PermanentRoles.cs ================================================ namespace PopForums.Models; public static class PermanentRoles { public const string Admin = "Admin"; public const string Moderator = "Moderator"; } ================================================ FILE: src/PopForums/Models/Post.cs ================================================ namespace PopForums.Models; public class Post { public int PostID { get; set; } public int TopicID { get; set; } public int ParentPostID { get; set; } public string IP { get; set; } public bool IsFirstInTopic { get; set; } public bool ShowSig { get; set; } public int UserID { get; set; } public string Name { get; set; } public string Title { get; set; } public string FullText { get; set; } public DateTime PostTime { get; set; } public bool IsEdited { get; set; } public string LastEditName { get; set; } public DateTime? LastEditTime { get; set; } public bool IsDeleted { get; set; } public int Votes { get; set; } } ================================================ FILE: src/PopForums/Models/PostEdit.cs ================================================ namespace PopForums.Models; public class PostEdit { public PostEdit() {} public PostEdit(Post post) { Title = post.Title; FullText = post.FullText; ShowSig = post.ShowSig; } public string Title { get; set; } public string FullText { get; set; } public bool ShowSig { get; set; } public string Comment { get; set; } public bool IsPlainText { get; set; } public bool IsFirstInTopic { get; set; } public string[] PostImageIDs { get; set; } } ================================================ FILE: src/PopForums/Models/PostImage.cs ================================================ namespace PopForums.Models; public class PostImage { public string ID { get; init; } public DateTime TimeStamp { get; init; } public string TenantID { get; init; } public string ContentType { get; init; } public byte[] ImageData { get; init; } } ================================================ FILE: src/PopForums/Models/PostImagePersistPayload.cs ================================================ namespace PopForums.Models; public class PostImagePersistPayload { public string Url { get; set; } public string ID { get; set; } } ================================================ FILE: src/PopForums/Models/PostItemContainer.cs ================================================ namespace PopForums.Models; public class PostItemContainer { public Post Post { get; set; } public List VotedPostIDs { get; set; } public Dictionary Signatures { get; set; } public Dictionary Avatars { get; set; } public User User { get; set; } public Profile Profile { get; set; } public Topic Topic { get; set; } public List IgnoreUserIDs { get; set; } } ================================================ FILE: src/PopForums/Models/PostWithChildren.cs ================================================ namespace PopForums.Models; public class PostWithChildren { public Post Post { get; set; } public List Children { get; set; } public DateTime? LastReadTime { get; set; } } ================================================ FILE: src/PopForums/Models/PrivateMessage.cs ================================================ namespace PopForums.Models; public class PrivateMessage { public int PMID { get; set; } public DateTime LastPostTime { get; set; } public JsonElement Users { get; set; } public DateTime LastViewDate { get; set; } public static string GetUserNames(PrivateMessage pm, int excludeUserID) { var users = pm.Users.Deserialize(new JsonSerializerOptions{PropertyNamingPolicy = JsonNamingPolicy.CamelCase}); if (users == null) return string.Empty; var names = new List(); foreach (var item in users) { if (item.UserID != excludeUserID) names.Add(item.Name as string); } var result = string.Join(", ", names); return result; } private class UserNamePair { public int UserID { get; set; } public string Name { get; set; } } } ================================================ FILE: src/PopForums/Models/PrivateMessageBoxType.cs ================================================ namespace PopForums.Models; public enum PrivateMessageBoxType { Inbox = 1, Archive = 2 } ================================================ FILE: src/PopForums/Models/PrivateMessagePost.cs ================================================ namespace PopForums.Models; public class PrivateMessagePost { public int PMPostID { get; set; } public int PMID { get; set; } public int UserID { get; set; } public string Name { get; set; } public DateTime PostTime { get; set; } public string FullText { get; set; } } ================================================ FILE: src/PopForums/Models/PrivateMessageState.cs ================================================ namespace PopForums.Models; public class PrivateMessageState { public int PmID { get; set; } public JsonElement Users { get; set; } public ClientPrivateMessagePost[] Messages { get; set; } public int? NewestPostID { get; set; } public bool IsUserNotFound { get; set; } } ================================================ FILE: src/PopForums/Models/PrivateMessageUser.cs ================================================ namespace PopForums.Models; public class PrivateMessageUser { public int PMID { get; set; } public int UserID { get; set; } public DateTime LastViewDate { get; set; } public bool IsArchived { get; set; } } ================================================ FILE: src/PopForums/Models/PrivateMessageView.cs ================================================ namespace PopForums.Models; public class PrivateMessageView { public PrivateMessage PrivateMessage { get; set; } public PrivateMessageState State { get; set; } } ================================================ FILE: src/PopForums/Models/Profile.cs ================================================ namespace PopForums.Models; public class Profile { public int UserID { get; set; } public bool IsSubscribed { get; set; } public string Signature { get; set; } public bool ShowDetails { get; set; } public string Location { get; set; } public bool IsPlainText { get; set; } public DateTime? Dob { get; set; } public string Web { get; set; } public string Facebook { get; set; } public string Instagram { get; set; } public bool IsTos { get; set; } public int? AvatarID { get; set; } public int? ImageID { get; set; } public bool HideVanity { get; set; } public int? LastPostID { get; set; } public int Points { get; set; } public bool IsAutoFollowOnReply { get; set; } } ================================================ FILE: src/PopForums/Models/QAPostItemContainer.cs ================================================ namespace PopForums.Models; public class QAPostItemContainer : PostItemContainer { public PostWithChildren PostWithChildren { get; set; } } ================================================ FILE: src/PopForums/Models/QueuedEmailMessage.cs ================================================ namespace PopForums.Models; public class QueuedEmailMessage : EmailMessage { public int MessageID { get; set; } public DateTime QueueTime { get; set; } } ================================================ FILE: src/PopForums/Models/ReadStatus.cs ================================================ namespace PopForums.Models; [Flags] public enum ReadStatus { NoNewPosts = 1, NewPosts = 2, Closed = 4, Open = 8, Pinned = 16, NotPinned = 32 } ================================================ FILE: src/PopForums/Models/ResponseOfT.cs ================================================ namespace PopForums.Models; /// /// A generic container for wrapping the response of external calls for consumption by internal service. /// /// public class Response where T : class { /// /// Creates a generic Response with IsValid set to true and no debug information or exception. /// /// The strongly typed result to be consumed by the caller. public Response(T data) { Data = data; IsValid = true; } /// /// Creates a generic response with all fields set. /// /// /// Default is false. /// Default is null. /// Default is null. public Response(T data, bool isValid = false, Exception exception = null, string debugInfo = null) { Data = data; IsValid = isValid; Exception = exception; DebugInfo = debugInfo; } public T Data { get; } public bool IsValid { get; } public Exception Exception { get; } public string DebugInfo { get; } } ================================================ FILE: src/PopForums/Models/SearchIndexPayload.cs ================================================ namespace PopForums.Models; public class SearchIndexPayload { public int TopicID { get; set; } public string TenantID { get; set; } public bool IsForRemoval { get; set; } } ================================================ FILE: src/PopForums/Models/SearchType.cs ================================================ namespace PopForums.Models; public enum SearchType { Rank, Date, Title, Name, Replies } ================================================ FILE: src/PopForums/Models/SearchWord.cs ================================================ namespace PopForums.Models; public class SearchWord { public string Word { get; set; } public int TopicID { get; set; } public int Rank { get; set; } } ================================================ FILE: src/PopForums/Models/SecurityLogEntry.cs ================================================ namespace PopForums.Models; public class SecurityLogEntry { public int SecurityLogID { get; set; } public SecurityLogType SecurityLogType { get; set; } public string SecurityLogTypeString => SecurityLogType.ToString(); public int? UserID { get; set; } public int? TargetUserID { get; set; } public string IP { get; set; } public string Message { get; set; } public DateTime ActivityDate { get; set; } } ================================================ FILE: src/PopForums/Models/SecurityLogType.cs ================================================ namespace PopForums.Models; public enum SecurityLogType { Undefined = 0, Login = 1, Logout = 2, PasswordChange = 3, EmailChange = 4, FailedLogin = 5, UserCreated = 6, UserDeleted = 7, RoleCreated = 8, RoleDeleted = 9, UserAddedToRole = 10, UserRemovedFromRole = 11, UserSessionStart = 12, UserSessionEnd = 13, NameChange = 14, IsApproved = 15, IsNotApproved = 16, ExternalAssociationSet = 17, ExternalAssociationRemoved = 18, ExternalAssociationCheckSuccessful = 19, ExternalAssociationCheckFailed = 20, ReCaptchaFailed = 21, ExternalLoginChallengeFailed = 22 } ================================================ FILE: src/PopForums/Models/ServiceHeartbeat.cs ================================================ namespace PopForums.Models; public class ServiceHeartbeat { public string ServiceName { get; set; } public string MachineName { get; set; } public DateTime LastRun { get; set; } } ================================================ FILE: src/PopForums/Models/SetupVariables.cs ================================================ namespace PopForums.Models; public class SetupVariables { public string Name { get; set; } public string Email { get; set; } public string Password { get; set; } public string ForumTitle { get; set; } public string SmtpServer { get; set; } public int SmtpPort { get; set; } public string MailerAddress { get; set; } public bool UseEsmtp { get; set; } public string SmtpUser { get; set; } public string SmtpPassword { get; set; } public bool UseSslSmtp { get; set; } } ================================================ FILE: src/PopForums/Models/SignupData.cs ================================================ namespace PopForums.Models; public class SignupData { public string Name { get; set; } public string Email { get; set; } public string Password { get; set; } public string PasswordRetype { get; set; } public bool IsSubscribed { get; set; } public bool IsCoppa { get; set; } public bool IsTos { get; set; } public string Token { get; set; } public bool IsAutoFollowOnReply { get; set; } public static string GetCoppaDate() { return DateTime.Now.AddYears(-13).ToString("D"); } } ================================================ FILE: src/PopForums/Models/SingleString.cs ================================================ namespace PopForums.Models; public class SingleString { public string String { get; set; } } ================================================ FILE: src/PopForums/Models/SubscribeNotificationPayload.cs ================================================ namespace PopForums.Models; public class SubscribeNotificationPayload { public int TopicID { get; set; } public string TopicTitle { get; set; } public int PostingUserID { get; set; } public string PostingUserName { get; set; } public string TenantID { get; set; } } ================================================ FILE: src/PopForums/Models/TimeFormats.cs ================================================ namespace PopForums.Models; public class TimeFormats { public string TodayTime { get; set; } public string YesterdayTime { get; set; } public string MinutesAgo { get; set; } public string OneMinuteAgo { get; set; } public string LessThanMinute { get; set; } } ================================================ FILE: src/PopForums/Models/Topic.cs ================================================ namespace PopForums.Models; public class Topic { public int TopicID { get; set; } public int ForumID { get; set; } public string Title { get; set; } public int ReplyCount { get; set; } public int ViewCount { get; set; } public int StartedByUserID { get; set; } public string StartedByName { get; set; } public int LastPostUserID { get; set; } public string LastPostName { get; set; } public DateTime LastPostTime { get; set; } public bool IsClosed { get; set; } public bool IsPinned { get; set; } public bool IsDeleted { get; set; } public string UrlName { get; set; } public int? AnswerPostID { get; set; } } ================================================ FILE: src/PopForums/Models/TopicContainer.cs ================================================ namespace PopForums.Models; public class TopicContainer { public Forum Forum { get; set; } public Topic Topic { get; set; } public List Posts { get; set; } public PagerContext PagerContext { get; set; } public ForumPermissionContext PermissionContext { get; set; } public Dictionary Signatures { get; set; } public Dictionary Avatars { get; set; } public List VotedPostIDs { get; set; } public DateTime? LastReadTime { get; set; } public TopicState TopicState { get; set; } public List IgnoreUserIDs { get; set; } } ================================================ FILE: src/PopForums/Models/TopicContainerForQA.cs ================================================ namespace PopForums.Models; public class TopicContainerForQA : TopicContainer { public PostWithChildren QuestionPostWithComments { get; set; } public List AnswersWithComments { get; set; } } ================================================ FILE: src/PopForums/Models/TopicState.cs ================================================ namespace PopForums.Models; public class TopicState { public int TopicID { get; set; } public bool IsImageEnabled { get; set; } public bool IsSubscribed { get; set; } public bool IsFavorite { get; set; } public int? PageIndex { get; set; } public int? PageCount { get; set; } public int LastVisiblePostID { get; set; } public int? AnswerPostID { get; set; } } ================================================ FILE: src/PopForums/Models/TopicUnsubscribeContainer.cs ================================================ namespace PopForums.Models; public class TopicUnsubscribeContainer { public User User { get; set; } public Topic Topic { get; set; } } ================================================ FILE: src/PopForums/Models/User.cs ================================================ namespace PopForums.Models; public class User { public int UserID { get; set; } public DateTime CreationDate { get; set; } public string Name { get; set; } public string Email { get; set; } public Guid AuthorizationKey { get; set; } public bool IsApproved { get; set; } public DateTime? TokenExpiration { get; set; } public List Roles { get; set; } public bool IsInRole(string role) { if (Roles == null) throw new Exception("Roles not set for user."); return Roles.Contains(role); } } ================================================ FILE: src/PopForums/Models/UserEdit.cs ================================================ namespace PopForums.Models; public class UserEdit { public UserEdit() {} public UserEdit(User user, Profile profile) { UserID = user.UserID; Name = user.Name; Email = user.Email; IsApproved = user.IsApproved; IsSubscribed = profile.IsSubscribed; Signature = profile.Signature; ShowDetails = profile.ShowDetails; Location = profile.Location; IsPlainText = profile.IsPlainText; Dob = profile.Dob; Web = profile.Web; Instagram = profile.Instagram; Facebook = profile.Facebook; HideVanity = profile.HideVanity; Roles = user.Roles.ToArray(); AvatarID = profile.AvatarID; ImageID = profile.ImageID; IsAutoFollowOnReply = profile.IsAutoFollowOnReply; } public int UserID { get; set; } public string Name { get; set; } public string Email { get; set; } public string NewEmail { get; set; } public string NewPassword { get; set; } public bool IsApproved { get; set; } public bool IsSubscribed { get; set; } public string Signature { get; set; } public bool ShowDetails { get; set; } public string Location { get; set; } public bool IsPlainText { get; set; } public DateTime? Dob { get; set; } public string Web { get; set; } public string Instagram { get; set; } public string Facebook { get; set; } public bool HideVanity { get; set; } public string[] Roles { get; set; } public int? AvatarID { get; set; } public int? ImageID { get; set; } public bool DeleteAvatar { get; set; } public bool DeleteImage { get; set; } public bool IsAutoFollowOnReply { get; set; } } ================================================ FILE: src/PopForums/Models/UserEditProfile.cs ================================================ namespace PopForums.Models; public class UserEditProfile { public UserEditProfile() {} public UserEditProfile(Profile profile) { IsSubscribed = profile.IsSubscribed; Signature = profile.Signature; ShowDetails = profile.ShowDetails; Location = profile.Location; IsPlainText = profile.IsPlainText; Dob = profile.Dob; Web = profile.Web; Instagram = profile.Instagram; Facebook = profile.Facebook; HideVanity = profile.HideVanity; IsAutoFollowOnReply = profile.IsAutoFollowOnReply; } public bool IsSubscribed { get; set; } public string Signature { get; set; } public bool ShowDetails { get; set; } public string Location { get; set; } public bool IsPlainText { get; set; } public DateTime? Dob { get; set; } public string Web { get; set; } public string Instagram { get; set; } public string Facebook { get; set; } public bool HideVanity { get; set; } public bool IsAutoFollowOnReply { get; set; } } ================================================ FILE: src/PopForums/Models/UserEditSecurity.cs ================================================ namespace PopForums.Models; public class UserEditSecurity { public UserEditSecurity() {} public UserEditSecurity(User user, bool isNewUserApproved) { OldEmail = user.Email; IsNewUserApproved = isNewUserApproved; } public string OldPassword { get; set; } public string NewPassword { get; set; } public string NewPasswordRetype { get; set; } public string OldEmail { get; private set; } public string NewEmail { get; set; } public string NewEmailRetype { get; set; } public bool IsNewUserApproved { get; set; } public bool NewPasswordsMatch() { if (String.IsNullOrWhiteSpace(NewPassword) || String.IsNullOrWhiteSpace(NewPasswordRetype)) return false; return NewPassword == NewPasswordRetype; } public bool NewEmailsMatch() { if (String.IsNullOrWhiteSpace(NewEmail) || String.IsNullOrWhiteSpace(NewEmailRetype)) return false; return NewEmail == NewEmailRetype; } } ================================================ FILE: src/PopForums/Models/UserImage.cs ================================================ namespace PopForums.Models; public class UserImage { public int UserImageID { get; set; } public int UserID { get; set; } public int SortOrder { get; set; } public bool IsApproved { get; set; } } ================================================ FILE: src/PopForums/Models/UserImageApprovalContainer.cs ================================================ namespace PopForums.Models; public class UserImageApprovalContainer { public bool IsNewUserImageApproved { get; set; } public List Unapproved { get; set; } } public class UserImagePair { public User User { get; set; } public UserImage UserImage { get; set; } } ================================================ FILE: src/PopForums/Models/UserResult.cs ================================================ namespace PopForums.Models; public class UserResult { public int UserID { get; set; } public string Name { get; set; } public string Email { get; set; } public DateTime CreationDate { get; set; } public string IP { get; set; } } ================================================ FILE: src/PopForums/Models/UserSearch.cs ================================================ namespace PopForums.Models; public class UserSearch { public string SearchText { get; set; } public UserSearchType SearchType { get; set; } public enum UserSearchType { Name, Email, Role } } ================================================ FILE: src/PopForums/Models/VotePostContainer.cs ================================================ namespace PopForums.Models; public class VotePostContainer { public int PostID { get; set; } public int Votes { get; set; } public Dictionary Voters { get; set; } } ================================================ FILE: src/PopForums/PopForums.csproj ================================================ PopForums Class Library 22.0.0 Jeff Putz net10.0 PopForums PopForums true https://github.com/POPWorldMedia/POPForums https://github.com/POPWorldMedia/POPForums 2025, POP World Media, LLC MIT True True Resources.resx PublicResXFileCodeGenerator Resources.Designer.cs PopForums ================================================ FILE: src/PopForums/Repositories/IAwardCalculationQueueRepository.cs ================================================ namespace PopForums.Repositories; public interface IAwardCalculationQueueRepository { Task Enqueue(AwardCalculationPayload payload); Task> Dequeue(); } ================================================ FILE: src/PopForums/Repositories/IAwardConditionRepository.cs ================================================ namespace PopForums.Repositories; public interface IAwardConditionRepository { Task> GetConditions(string awardDefinitionID); Task DeleteConditions(string awardDefinitionID); Task SaveConditions(List conditions); Task DeleteConditionsByEventDefinitionID(string eventDefinitionID); Task DeleteCondition(string awardDefinitionID, string eventDefinitionID); } ================================================ FILE: src/PopForums/Repositories/IAwardDefinitionRepository.cs ================================================ namespace PopForums.Repositories; public interface IAwardDefinitionRepository { Task Get(string awardDefinitionID); Task> GetAll(); Task> GetByEventDefinitionID(string eventDefinitionID); Task Create(string awardDefinitionID, string title, string description, bool isSingleTimeAward); Task Delete(string awardDefinitionID); } ================================================ FILE: src/PopForums/Repositories/IBanRepository.cs ================================================ namespace PopForums.Repositories; public interface IBanRepository { Task BanIP(string ip); Task RemoveIPBan(string ip); Task> GetIPBans(); Task IPIsBanned(string ip); Task BanEmail(string email); Task RemoveEmailBan(string email); Task> GetEmailBans(); Task EmailIsBanned(string email); } ================================================ FILE: src/PopForums/Repositories/ICategoryRepository.cs ================================================ namespace PopForums.Repositories; public interface ICategoryRepository { Task Get(int categoryID); Task> GetAll(); Task Create(string newTitle, int sortOrder); Task Delete(int categoryID); Task Update(Category category); } ================================================ FILE: src/PopForums/Repositories/IEmailQueueRepository.cs ================================================ namespace PopForums.Repositories; public interface IEmailQueueRepository { Task Enqueue(EmailQueuePayload payload); Task Dequeue(); } ================================================ FILE: src/PopForums/Repositories/IErrorLogRepository.cs ================================================ namespace PopForums.Repositories; public interface IErrorLogRepository { Task Create(DateTime timeStamp, string message, string stackTrace, string data, ErrorSeverity severity); Task GetErrorCount(); Task> GetErrors(int startRow, int pageSize); Task DeleteError(int errorID); Task DeleteAllErrors(); } ================================================ FILE: src/PopForums/Repositories/IEventDefinitionRepository.cs ================================================ namespace PopForums.Repositories; public interface IEventDefinitionRepository { Task Get(string eventDefinitionID); Task> GetAll(); Task Create(EventDefinition eventDefinition); void Delete(string eventDefinitionID); } ================================================ FILE: src/PopForums/Repositories/IExternalUserAssociationRepository.cs ================================================ namespace PopForums.Repositories; public interface IExternalUserAssociationRepository { Task Get(string issuer, string providerKey); Task Get(int externalUserAssociationID); Task> GetByUser(int userID); Task Save(int userID, string issuer, string providerKey, string name); Task Delete(int externalUserAssociationID); } ================================================ FILE: src/PopForums/Repositories/IFavoriteTopicsRepository.cs ================================================ namespace PopForums.Repositories; public interface IFavoriteTopicsRepository { Task> GetFavoriteTopics(int userID, int startRow, int pageSize); Task GetFavoriteTopicCount(int userID); Task IsTopicFavorite(int userID, int topicID); Task AddFavoriteTopic(int userID, int topicID); Task RemoveFavoriteTopic(int userID, int topicID); } ================================================ FILE: src/PopForums/Repositories/IFeedRepository.cs ================================================ namespace PopForums.Repositories; public interface IFeedRepository { Task> GetFeed(int userID, int itemCount); Task PublishEvent(int userID, string message, int points, DateTime timeStamp); Task GetOldestTime(int userID, int takeCount); Task DeleteOlderThan(int userID, DateTime timeCutOff); Task> GetFeed(int itemCount); } ================================================ FILE: src/PopForums/Repositories/IForumRepository.cs ================================================ namespace PopForums.Repositories; public interface IForumRepository { Task Get(int forumID); Task Get(string urlName); Task Create(int? categoryID, string title, string description, bool isVisible, bool isArchived, int sortOrder, string urlName, string forumAdapterName, bool isQAForum); Task> GetForumsInCategory(int? categoryID); Task> GetUrlNamesThatStartWith(string urlName); Task Update(int forumID, int? categoryID, string title, string description, bool isVisible, bool isArchived, string urlName, string forumAdapterName, bool isQAForum); Task UpdateSortOrder(int forumID, int newSortOrder); Task UpdateCategoryAssociation(int forumID, int? categoryID); Task UpdateLastTimeAndUser(int forumID, DateTime lastTime, string lastName); Task UpdateTopicAndPostCounts(int forumID, int topicCount, int postCount); Task IncrementPostCount(int forumID); Task IncrementPostAndTopicCount(int forumID); Task> GetAll(); Task> GetAllVisible(); /// /// Gets role requirements for the ability to post in this forum. /// /// This should generally be cached, because the calling code runs this test on every forum when displayed. /// ID of forum to fetch posting roles for. /// A List of strings for roles required for posting. Task> GetForumPostRoles(int forumID); /// /// Gets role requirements for the ability to view this forum. /// /// This should generally be cached, because the calling code runs this test on every forum when displayed. /// ID of forum to fetch roles required for viewing. /// A List of strings for roles required for viewing. Task> GetForumViewRoles(int forumID); /// /// Gets a graph of forums and associated post restrictions. /// /// This should generally be cached, because the calling code runs this test on every forum when displayed. /// A dictionary of key/value pairs of forums and post role restrictions. Task>> GetForumPostRestrictionRoleGraph(); /// /// Gets a graph of forums and associated view restrictions. /// /// This should generally be cached, because the calling code runs this test on every forum when displayed. /// A dictionary of key/value pairs of forums and view role restrictions. Task>> GetForumViewRestrictionRoleGraph(); Task AddPostRole(int forumID, string role); Task RemovePostRole(int forumID, string role); Task AddViewRole(int forumID, string role); Task RemoveViewRole(int forumID, string role); Task RemoveAllPostRoles(int forumID); Task RemoveAllViewRoles(int forumID); /// /// Gets all UrlName values for all forums. /// /// This should generally be cached, as it's used as a lookup for URL routing on every request. /// An enumerable object of strings. Task> GetAllForumUrlNames(); Dictionary GetAllForumTitles(); Task GetAggregateTopicCount(); Task GetAggregatePostCount(); } ================================================ FILE: src/PopForums/Repositories/IIgnoreRepository.cs ================================================ namespace PopForums.Repositories; public interface IIgnoreRepository { Task AddIgnore(int userID, int ignoreUserID); Task DeleteIgnore(int userID, int ignoreUserID); Task> GetIgnoreList(int userID); Task> GetIgnoredUserIdsInList(int userID, List userIDs); } ================================================ FILE: src/PopForums/Repositories/ILastReadRepository.cs ================================================ namespace PopForums.Repositories; public interface ILastReadRepository { Task SetForumRead(int userID, int forumID, DateTime readTime); Task DeleteTopicReadsInForum(int userID, int forumID); Task SetAllForumsRead(int userID, DateTime readTime); Task DeleteAllTopicReads(int userID); Task SetTopicRead(int userID, int topicID, DateTime readTime); Task> GetLastReadTimesForForums(int userID); Task GetLastReadTimesForForum(int userID, int forumID); Task> GetLastReadTimesForTopics(int userID, IEnumerable topicIDs); Task GetLastReadTimeForTopic(int userID, int topicID); } ================================================ FILE: src/PopForums/Repositories/IModerationLogRepository.cs ================================================ namespace PopForums.Repositories; public interface IModerationLogRepository { Task Log(DateTime timeStamp, int userID, string userName, int moderationType, int? forumID, int topicID, int? postID, string comment, string oldText); Task> GetLog(DateTime start, DateTime end); Task> GetLog(int topicID, bool excludePostEntries); Task> GetLog(int postID); } ================================================ FILE: src/PopForums/Repositories/INotificationRepository.cs ================================================ namespace PopForums.Repositories; public interface INotificationRepository { Task UpdateNotification(Notification notification); Task CreateNotification(Notification notification); Task MarkNotificationRead(int userID, NotificationType notificationType, long contextID); Task> GetNotifications(int userID, DateTime afterDateTime, int pageSize); Task GetPageCount(int userID, int pageSize); Task GetUnreadNotificationCount(int userID); Task MarkAllRead(int userID); Task DeleteOlderThan(int userID, DateTime timeCutOff); } ================================================ FILE: src/PopForums/Repositories/IPointLedgerRepository.cs ================================================ namespace PopForums.Repositories; public interface IPointLedgerRepository { Task RecordEntry(PointLedgerEntry entry); Task GetPointTotal(int userID); Task GetEntryCount(int userID, string eventDefinitionID); } ================================================ FILE: src/PopForums/Repositories/IPostImageRepository.cs ================================================ namespace PopForums.Repositories; public interface IPostImageRepository { Task Persist(byte[] bytes, string contentType); Task GetWithoutData(string id); [Obsolete("Use the combination of GetWithoutData(int) and GetImageStream(int) instead.")] Task Get(string id); Task DeletePostImageData(string id, string tenantID); Task GetImageStream(string id); } ================================================ FILE: src/PopForums/Repositories/IPostImageTempRepository.cs ================================================ namespace PopForums.Repositories; public interface IPostImageTempRepository { Task Save(Guid postImageTempID, DateTime timeStamp, string tenantID); Task Delete(Guid id); Task> GetOld(DateTime olderThan); } ================================================ FILE: src/PopForums/Repositories/IPostRepository.cs ================================================ namespace PopForums.Repositories; public interface IPostRepository { Task Create(int topicID, int parentPostID, string IP, bool isFirstInTopic, bool showSig, int userID, string name, string title, string fullText, DateTime postTime, bool isEdited, string lastEditName, DateTime? lastEditTime, bool isDeleted, int votes); Task Update(Post post); Task> Get(int topicID, bool includeDeleted, int startRow, int pageSize); Task> Get(int topicID, bool includeDeleted); Task GetReplyCount(int topicID, bool includeDeleted); Task Get(int postID); Task> GetPostIDsWithTimes(int topicID, bool includeDeleted); Task GetPostCount(int userID); Task> GetIPHistory(string ip, DateTime start, DateTime end); Task GetLastPostID(int topicID); Task GetVoteCount(int postID); Task CalculateVoteCount(int postID); Task SetVoteCount(int postID, int votes); Task VotePost(int postID, int userID); Task> GetVotes(int postID); Task> GetVotedPostIDs(int userID, List postIDs); Task GetLastInTopic(int topicID); Task DeleteVote(int postID, int userID); } ================================================ FILE: src/PopForums/Repositories/IPrivateMessageRepository.cs ================================================ namespace PopForums.Repositories; public interface IPrivateMessageRepository { Task Get(int pmID, int userID); Task> GetPosts(int pmID, DateTime afterDateTime); Task> GetPosts(int pmID, DateTime beforeDateTime, int pageSize); Task CreatePrivateMessage(PrivateMessage pm); Task AddUsers(int pmID, List userIDs, DateTime viewDate, bool isArchived); Task AddPost(PrivateMessagePost post); Task> GetUsers(int pmID); Task SetLastViewTime(int pmID, int userID, DateTime viewDate); Task SetArchive(int pmID, int userID, bool isArchived); Task> GetPrivateMessages(int userID, PrivateMessageBoxType boxType, int startRow, int pageSize); Task GetUnreadCount(int userID); Task GetBoxCount(int userID, PrivateMessageBoxType boxType); Task UpdateLastPostTime(int pmID, DateTime lastPostTime); Task GetExistingFromIDs(List ids); Task GetFirstUnreadPostID(int pmID, DateTime lastReadTime); } ================================================ FILE: src/PopForums/Repositories/IProfileRepository.cs ================================================ namespace PopForums.Repositories; public interface IProfileRepository { Task GetProfile(int userID); Task Create(Profile profile); Task Update(Profile profile); Task GetLastPostID(int userID); Task SetLastPostID(int userID, int postID); Task> GetSignatures(List userIDs); Task> GetAvatars(List userIDs); Task SetCurrentImageIDToNull(int userID); Task UpdatePoints(int userID, int points); } ================================================ FILE: src/PopForums/Repositories/IQueuedEmailMessageRepository.cs ================================================ namespace PopForums.Repositories; public interface IQueuedEmailMessageRepository { Task CreateMessage(QueuedEmailMessage message); Task DeleteMessage(int messageID); Task GetMessage(int messageID); } ================================================ FILE: src/PopForums/Repositories/IRoleRepository.cs ================================================ namespace PopForums.Repositories; public interface IRoleRepository { Task CreateRole(string role); Task DeleteRole(string role); Task> GetAllRoles(); Task> GetUserRoles(int userID); Task ReplaceUserRoles(int userID, string[] roles); } ================================================ FILE: src/PopForums/Repositories/ISearchIndexQueueRepository.cs ================================================ namespace PopForums.Repositories; public interface ISearchIndexQueueRepository { Task Enqueue(SearchIndexPayload payload); Task Dequeue(); } ================================================ FILE: src/PopForums/Repositories/ISearchRepository.cs ================================================ namespace PopForums.Repositories; public interface ISearchRepository { Task> GetJunkWords(); Task CreateJunkWord(string word); Task DeleteJunkWord(string word); Task DeleteAllIndexedWordsForTopic(int topicID); Task SaveSearchWord(int topicID, string word, int rank); Task>, int>> SearchTopics(string searchTerm, List hiddenForums, SearchType searchType, int startRow, int pageSize); } ================================================ FILE: src/PopForums/Repositories/ISecurityLogRepository.cs ================================================ namespace PopForums.Repositories; public interface ISecurityLogRepository { Task Create(SecurityLogEntry logEntry); Task> GetByUserID(int userID, DateTime startDate, DateTime endDate); Task> GetIPHistory(string ip, DateTime start, DateTime end); } ================================================ FILE: src/PopForums/Repositories/IServiceHeartbeatRepository.cs ================================================ namespace PopForums.Repositories; public interface IServiceHeartbeatRepository { Task RecordHeartbeat(string serviceName, string machineName, DateTime lastRun); Task> GetAll(); Task ClearAll(); } ================================================ FILE: src/PopForums/Repositories/ISettingsRepository.cs ================================================ namespace PopForums.Repositories; public interface ISettingsRepository { Dictionary Get(); void Save(Dictionary dictionary); event Action OnSettingsInvalidated; } ================================================ FILE: src/PopForums/Repositories/ISetupRepository.cs ================================================ namespace PopForums.Repositories; public interface ISetupRepository { bool IsConnectionPossible(); bool IsDatabaseSetup(); void SetupDatabase(); } ================================================ FILE: src/PopForums/Repositories/ISubscribeNotificationRepository.cs ================================================ namespace PopForums.Repositories; public interface ISubscribeNotificationRepository { Task Enqueue(SubscribeNotificationPayload payload); Task Dequeue(); } ================================================ FILE: src/PopForums/Repositories/ISubscribedTopicsRepository.cs ================================================ namespace PopForums.Repositories; public interface ISubscribedTopicsRepository { Task> GetSubscribedTopics(int userID, int startRow, int pageSize); Task GetSubscribedTopicCount(int userID); Task IsTopicSubscribed(int userID, int topicID); Task AddSubscribedTopic(int userID, int topicID); Task RemoveSubscribedTopic(int userID, int topicID); Task> GetSubscribedUserIDs(int topicID); } ================================================ FILE: src/PopForums/Repositories/ITopicRepository.cs ================================================ namespace PopForums.Repositories; public interface ITopicRepository { Task GetLastUpdatedTopic(int forumID); Task GetTopicCount(int forumID, bool includeDeleted); Task GetTopicCountByUser(int userID, bool includeDeleted, List excludedForums); Task GetTopicCount(bool includeDeleted, List excludedForums); Task GetPostCount(int forumID, bool includeDelete); Task Get(int topicID); Task Get(string urlName); Task> Get(int forumID, bool includeDeleted, int startRow, int pageSize); Task> GetTopicsByUser(int userID, bool includeDeleted, List excludedForums, int startRow, int pageSize); Task> Get(bool includeDeleted, List excludedForums, int startRow, int pageSize); Task> Get(int forumID, bool includeDeleted, List excludedForums); Task> GetUrlNamesThatStartWith(string urlName); Task Create(int forumID, string title, int replyCount, int viewCount, int startedByUserID, string startedByName, int lastPostUserID, string lastPostName, DateTime lastPostTime, bool isClosed, bool isPinned, bool isDeleted, string urlName); Task IncrementReplyCount(int topicID); Task IncrementViewCount(int topicID); Task UpdateLastTimeAndUser(int topicID, int userID, string name, DateTime postTime); Task CloseTopic(int topicID); Task OpenTopic(int topicID); Task PinTopic(int topicID); Task UnpinTopic(int topicID); Task DeleteTopic(int topicID); Task UndeleteTopic(int topicID); Task UpdateTitleAndForum(int topicID, int forumID, string newTitle, string newUrlName); Task UpdateReplyCount(int topicID, int replyCount); Task GetLastPostTime(int topicID); Task HardDeleteTopic(int topicID); Task UpdateAnswerPostID(int topicID, int? postID); Task> Get(IEnumerable topicIDs); Task> CloseTopicsOlderThan(DateTime cutoffDate); Task>> GetUrlNames(bool includeDeleted, List excludedForums, int startRow, int pageSize); } ================================================ FILE: src/PopForums/Repositories/ITopicViewLogRepository.cs ================================================ namespace PopForums.Repositories; public interface ITopicViewLogRepository { Task Log(int? userID, int topicID, DateTime timeStamp); } ================================================ FILE: src/PopForums/Repositories/IUserAvatarRepository.cs ================================================ namespace PopForums.Repositories; public interface IUserAvatarRepository { [Obsolete("Use GetImageData(int) instead.")] Task GetImageData(int userAvatarID); Task GetImageStream(int userAvatarID); Task> GetUserAvatarIDs(int userID); Task SaveNewAvatar(int userID, byte[] imageData, DateTime timeStamp); Task DeleteAvatarsByUserID(int userID); Task GetLastModificationDate(int userAvatarID); } ================================================ FILE: src/PopForums/Repositories/IUserAwardRepository.cs ================================================ namespace PopForums.Repositories; public interface IUserAwardRepository { Task IssueAward(int userID, string awardDefinitionID, string title, string description, DateTime timeStamp); Task IsAwarded(int userID, string awardDefinitionID); Task> GetAwards(int userID); } ================================================ FILE: src/PopForums/Repositories/IUserImageRepository.cs ================================================ namespace PopForums.Repositories; public interface IUserImageRepository { [Obsolete("Use GetImageStream instead.")] Task GetImageData(int userImageID); Task GetImageStream(int userImageID); Task> GetUserImages(int userID); Task SaveNewImage(int userID, int sortOrder, bool isApproved, byte[] imageData, DateTime timeStamp); Task DeleteImagesByUserID(int userID); Task GetLastModificationDate(int userImageID); Task> GetUnapprovedUserImages(); Task IsUserImageApproved(int userImageID); Task ApproveUserImage(int userImageID); Task DeleteUserImage(int userImageID); Task Get(int userImageID); } ================================================ FILE: src/PopForums/Repositories/IUserRepository.cs ================================================ namespace PopForums.Repositories; public interface IUserRepository { /// /// Stores the hashed password in the data store. /// /// User to update with new hashed password. /// The string of the hashed password. /// A Guid that was used in hashing the password. Task SetHashedPassword(User user, string hashedPassword, Guid salt); /// /// Gets the hashed password and salt from the data store of the user whose e-mail is matched. /// /// /// A tuple containing the hashed password and salt. Task> GetHashedPasswordByEmail(string email); /// /// Gets a user by its ID. /// /// UserID to match. /// Matched user, or null if no matching user is found. Task GetUser(int userID); /// /// Gets a user by name. Match must be case insensitive. /// /// Name to match. /// Matched user, or null if no matching user is found. Task GetUserByName(string name); /// /// Gets a user by e-mail address. Match must be case insensitive. /// /// E-mail to match. /// Matched user, or null if no matching user is found. Task GetUserByEmail(string email); Task GetUserByAuthorizationKey(Guid key); /// /// Stores new user data and returns a new User object, with new UserID. /// /// Name for new user. Must not be in use. /// E-mail address for new user. Must not be in use. /// Date (UTC) to assign as creation date. LastActivityDate and LastLoginDate will be set to this as well. /// Boolean indicating user approval. /// The hashed password string. /// A Guid used for authorization measures. /// A Guid used to salt the password. /// A new User object, populated with the data store generated UserID. Task CreateUser(string name, string email, DateTime creationDate, bool isApproved, string hashedPassword, Guid authorizationKey, Guid salt); /// /// Updates a user record in the data store with a new LastActivityDate. /// /// User to update. /// New value for LastActivityDate value. Task UpdateLastActivityDate(User user, DateTime newDate); /// /// Updates a user record in the data store with a new LastLoginDate. /// /// User to update. /// New value for LastActivityDate value. Task UpdateLastLoginDate(User user, DateTime newDate); /// /// Updates a user record with a new name. /// /// User to update. /// New name. Must not be in use by another user, null or empty. Task ChangeName(User user, string newName); /// /// Updates a user record with a new e-mail address. /// /// User to update. /// New e-mail address. Must not be in use by another user. Task ChangeEmail(User user, string newEmail); Task UpdateIsApproved(User user, bool isApproved); Task UpdateAuthorizationKey(User user, Guid key); Task> SearchByEmail(string email); Task> SearchByName(string name); Task> SearchByRole(string role); Task> GetUsersOnline(); Task> GetSubscribedUsers(); Task DeleteUser(User user); Task> GetUsersFromIDs(IList ids); Task GetTotalUsers(); Dictionary GetUsersByPointTotals(int top); Task> GetRecentUsers(); Task UpdateTokenExpiration(User user, DateTime? tokenExpiration); Task UpdateRefreshToken(User user, string refreshToken); Task GetRefreshToken(User user); Task> GetUserNamesThatStartWith(string startingName); } ================================================ FILE: src/PopForums/Repositories/IUserSessionRepository.cs ================================================ namespace PopForums.Repositories; public interface IUserSessionRepository { Task CreateSession(int sessionID, int? userID, DateTime lastTime); Task UpdateSession(int sessionID, DateTime lastTime); Task IsSessionAnonymous(int sessionID); Task> GetAndDeleteExpiredSessions(DateTime cutOffDate); Task GetSessionIDByUserID(int userID); Task DeleteSessions(int? userID, int sessionID); Task GetTotalSessionCount(); } ================================================ FILE: src/PopForums/Resources/Resources.Designer.cs ================================================ //------------------------------------------------------------------------------ // // This code was generated by a tool. // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. // //------------------------------------------------------------------------------ namespace PopForums { using System; [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] [System.Diagnostics.DebuggerNonUserCodeAttribute()] [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class Resources { private static System.Resources.ResourceManager resourceMan; private static System.Globalization.CultureInfo resourceCulture; [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] internal Resources() { } [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] public static System.Resources.ResourceManager ResourceManager { get { if (object.Equals(null, resourceMan)) { System.Resources.ResourceManager temp = new System.Resources.ResourceManager("PopForums.Resources.Resources", typeof(Resources).Assembly); resourceMan = temp; } return resourceMan; } } [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] public static System.Globalization.CultureInfo Culture { get { return resourceCulture; } set { resourceCulture = value; } } public static string Account { get { return ResourceManager.GetString("Account", resourceCulture); } } public static string AccountCreated { get { return ResourceManager.GetString("AccountCreated", resourceCulture); } } public static string AccountReady { get { return ResourceManager.GetString("AccountReady", resourceCulture); } } public static string AccountReadyCheckEmail { get { return ResourceManager.GetString("AccountReadyCheckEmail", resourceCulture); } } public static string AccountVerified { get { return ResourceManager.GetString("AccountVerified", resourceCulture); } } public static string AlreadyCreatedAccount { get { return ResourceManager.GetString("AlreadyCreatedAccount", resourceCulture); } } public static string AsYouWouldLikeItToAppear { get { return ResourceManager.GetString("AsYouWouldLikeItToAppear", resourceCulture); } } public static string AtLeastSixChar { get { return ResourceManager.GetString("AtLeastSixChar", resourceCulture); } } public static string By { get { return ResourceManager.GetString("By", resourceCulture); } } public static string CreateAccountButton { get { return ResourceManager.GetString("CreateAccountButton", resourceCulture); } } public static string CreateAnAccount { get { return ResourceManager.GetString("CreateAnAccount", resourceCulture); } } public static string EditYourProfile { get { return ResourceManager.GetString("EditYourProfile", resourceCulture); } } public static string Email { get { return ResourceManager.GetString("Email", resourceCulture); } } public static string EmailProblemAccount { get { return ResourceManager.GetString("EmailProblemAccount", resourceCulture); } } public static string Favorites { get { return ResourceManager.GetString("Favorites", resourceCulture); } } public static string Forbidden { get { return ResourceManager.GetString("Forbidden", resourceCulture); } } public static string Forums { get { return ResourceManager.GetString("Forums", resourceCulture); } } public static string HaveReadTOS { get { return ResourceManager.GetString("HaveReadTOS", resourceCulture); } } public static string Last { get { return ResourceManager.GetString("Last", resourceCulture); } } public static string Login { get { return ResourceManager.GetString("Login", resourceCulture); } } public static string Logout { get { return ResourceManager.GetString("Logout", resourceCulture); } } public static string MarkAllForumsRead { get { return ResourceManager.GetString("MarkAllForumsRead", resourceCulture); } } public static string MyBirthdayIsOnOrBefore { get { return ResourceManager.GetString("MyBirthdayIsOnOrBefore", resourceCulture); } } public static string Name { get { return ResourceManager.GetString("Name", resourceCulture); } } public static string NeedToVerifyExistingAccount { get { return ResourceManager.GetString("NeedToVerifyExistingAccount", resourceCulture); } } public static string PageNotFound { get { return ResourceManager.GetString("PageNotFound", resourceCulture); } } public static string Password { get { return ResourceManager.GetString("Password", resourceCulture); } } public static string Posts { get { return ResourceManager.GetString("Posts", resourceCulture); } } public static string PrivateMessages { get { return ResourceManager.GetString("PrivateMessages", resourceCulture); } } public static string Recent { get { return ResourceManager.GetString("Recent", resourceCulture); } } public static string RegisteredUsers { get { return ResourceManager.GetString("RegisteredUsers", resourceCulture); } } public static string RetypePassword { get { return ResourceManager.GetString("RetypePassword", resourceCulture); } } public static string Search { get { return ResourceManager.GetString("Search", resourceCulture); } } public static string SubscribeToList { get { return ResourceManager.GetString("SubscribeToList", resourceCulture); } } public static string Subscriptions { get { return ResourceManager.GetString("Subscriptions", resourceCulture); } } public static string TermsOfService { get { return ResourceManager.GetString("TermsOfService", resourceCulture); } } public static string TimeZone { get { return ResourceManager.GetString("TimeZone", resourceCulture); } } public static string Topics { get { return ResourceManager.GetString("Topics", resourceCulture); } } public static string Total { get { return ResourceManager.GetString("Total", resourceCulture); } } public static string TotalPosts { get { return ResourceManager.GetString("TotalPosts", resourceCulture); } } public static string TotalTopics { get { return ResourceManager.GetString("TotalTopics", resourceCulture); } } public static string UseDaylight { get { return ResourceManager.GetString("UseDaylight", resourceCulture); } } public static string UsersOnline { get { return ResourceManager.GetString("UsersOnline", resourceCulture); } } public static string MustBe13 { get { return ResourceManager.GetString("MustBe13", resourceCulture); } } public static string MustAcceptTOS { get { return ResourceManager.GetString("MustAcceptTOS", resourceCulture); } } public static string RetypeYourPassword { get { return ResourceManager.GetString("RetypeYourPassword", resourceCulture); } } public static string NameRequired { get { return ResourceManager.GetString("NameRequired", resourceCulture); } } public static string NameInUse { get { return ResourceManager.GetString("NameInUse", resourceCulture); } } public static string EmailRequired { get { return ResourceManager.GetString("EmailRequired", resourceCulture); } } public static string ValidEmailAddressRequired { get { return ResourceManager.GetString("ValidEmailAddressRequired", resourceCulture); } } public static string EmailInUse { get { return ResourceManager.GetString("EmailInUse", resourceCulture); } } public static string EmailBanned { get { return ResourceManager.GetString("EmailBanned", resourceCulture); } } public static string IPBanned { get { return ResourceManager.GetString("IPBanned", resourceCulture); } } public static string RetypePasswordMustMatch { get { return ResourceManager.GetString("RetypePasswordMustMatch", resourceCulture); } } public static string EditAccount { get { return ResourceManager.GetString("EditAccount", resourceCulture); } } public static string EditProfile { get { return ResourceManager.GetString("EditProfile", resourceCulture); } } public static string EnterVerificationCode { get { return ResourceManager.GetString("EnterVerificationCode", resourceCulture); } } public static string ForumHomePage { get { return ResourceManager.GetString("ForumHomePage", resourceCulture); } } public static string MustBeRegisteredToEditAccount { get { return ResourceManager.GetString("MustBeRegisteredToEditAccount", resourceCulture); } } public static string NoUserFoundWithEmail { get { return ResourceManager.GetString("NoUserFoundWithEmail", resourceCulture); } } public static string SendEmailWithNewCodeButton { get { return ResourceManager.GetString("SendEmailWithNewCodeButton", resourceCulture); } } public static string VerificationEmailSent { get { return ResourceManager.GetString("VerificationEmailSent", resourceCulture); } } public static string VerificationFailure { get { return ResourceManager.GetString("VerificationFailure", resourceCulture); } } public static string VerificationIfYouNeed { get { return ResourceManager.GetString("VerificationIfYouNeed", resourceCulture); } } public static string VerificationLinkBad { get { return ResourceManager.GetString("VerificationLinkBad", resourceCulture); } } public static string VerifyAccount { get { return ResourceManager.GetString("VerifyAccount", resourceCulture); } } public static string VerifyCodeButton { get { return ResourceManager.GetString("VerifyCodeButton", resourceCulture); } } public static string Avatar { get { return ResourceManager.GetString("Avatar", resourceCulture); } } public static string AvatarDelete { get { return ResourceManager.GetString("AvatarDelete", resourceCulture); } } public static string ChangeEmail { get { return ResourceManager.GetString("ChangeEmail", resourceCulture); } } public static string ChangeEmailButton { get { return ResourceManager.GetString("ChangeEmailButton", resourceCulture); } } public static string ChangeEmailConsequence { get { return ResourceManager.GetString("ChangeEmailConsequence", resourceCulture); } } public static string ChangePassword { get { return ResourceManager.GetString("ChangePassword", resourceCulture); } } public static string ChangeYourEmailPassword { get { return ResourceManager.GetString("ChangeYourEmailPassword", resourceCulture); } } public static string DateOfBirth { get { return ResourceManager.GetString("DateOfBirth", resourceCulture); } } public static string Details { get { return ResourceManager.GetString("Details", resourceCulture); } } public static string EmailChangeSuccess { get { return ResourceManager.GetString("EmailChangeSuccess", resourceCulture); } } public static string EmailNew { get { return ResourceManager.GetString("EmailNew", resourceCulture); } } public static string EmailNewRetype { get { return ResourceManager.GetString("EmailNewRetype", resourceCulture); } } public static string EmailNotFound { get { return ResourceManager.GetString("EmailNotFound", resourceCulture); } } public static string EmailSent { get { return ResourceManager.GetString("EmailSent", resourceCulture); } } public static string EmailsMustMatch { get { return ResourceManager.GetString("EmailsMustMatch", resourceCulture); } } public static string EmailUser { get { return ResourceManager.GetString("EmailUser", resourceCulture); } } public static string ForcePlainTextBox { get { return ResourceManager.GetString("ForcePlainTextBox", resourceCulture); } } public static string ForgotInstructions { get { return ResourceManager.GetString("ForgotInstructions", resourceCulture); } } public static string ForgotInstructionsSent { get { return ResourceManager.GetString("ForgotInstructionsSent", resourceCulture); } } public static string ForgotPassword { get { return ResourceManager.GetString("ForgotPassword", resourceCulture); } } public static string ForgotPasswordQuestion { get { return ResourceManager.GetString("ForgotPasswordQuestion", resourceCulture); } } public static string HideVanity { get { return ResourceManager.GetString("HideVanity", resourceCulture); } } public static string In { get { return ResourceManager.GetString("In", resourceCulture); } } public static string Joined { get { return ResourceManager.GetString("Joined", resourceCulture); } } public static string Location { get { return ResourceManager.GetString("Location", resourceCulture); } } public static string LoginAlready { get { return ResourceManager.GetString("LoginAlready", resourceCulture); } } public static string LoginBad { get { return ResourceManager.GetString("LoginBad", resourceCulture); } } public static string ManagePhotos { get { return ResourceManager.GetString("ManagePhotos", resourceCulture); } } public static string ManageYourPhotos { get { return ResourceManager.GetString("ManageYourPhotos", resourceCulture); } } public static string NewPasswordSaved { get { return ResourceManager.GetString("NewPasswordSaved", resourceCulture); } } public static string NotRegisteredQuestion { get { return ResourceManager.GetString("NotRegisteredQuestion", resourceCulture); } } public static string OldPasswordIncorrect { get { return ResourceManager.GetString("OldPasswordIncorrect", resourceCulture); } } public static string Options { get { return ResourceManager.GetString("Options", resourceCulture); } } public static string PasswordNew { get { return ResourceManager.GetString("PasswordNew", resourceCulture); } } public static string PasswordNewRetype { get { return ResourceManager.GetString("PasswordNewRetype", resourceCulture); } } public static string PasswordOld { get { return ResourceManager.GetString("PasswordOld", resourceCulture); } } public static string PasswordReset { get { return ResourceManager.GetString("PasswordReset", resourceCulture); } } public static string PasswordResetLinkInvalid { get { return ResourceManager.GetString("PasswordResetLinkInvalid", resourceCulture); } } public static string PasswordResetNote { get { return ResourceManager.GetString("PasswordResetNote", resourceCulture); } } public static string PasswordResetSuccess { get { return ResourceManager.GetString("PasswordResetSuccess", resourceCulture); } } public static string Photo { get { return ResourceManager.GetString("Photo", resourceCulture); } } public static string PhotoDelete { get { return ResourceManager.GetString("PhotoDelete", resourceCulture); } } public static string PhotoNotApproved { get { return ResourceManager.GetString("PhotoNotApproved", resourceCulture); } } public static string ProfileUpdated { get { return ResourceManager.GetString("ProfileUpdated", resourceCulture); } } public static string RememberMe { get { return ResourceManager.GetString("RememberMe", resourceCulture); } } public static string Replies { get { return ResourceManager.GetString("Replies", resourceCulture); } } public static string Save { get { return ResourceManager.GetString("Save", resourceCulture); } } public static string SendEmailButton { get { return ResourceManager.GetString("SendEmailButton", resourceCulture); } } public static string SendPM { get { return ResourceManager.GetString("SendPM", resourceCulture); } } public static string ShowProfileDetails { get { return ResourceManager.GetString("ShowProfileDetails", resourceCulture); } } public static string Signature { get { return ResourceManager.GetString("Signature", resourceCulture); } } public static string StartedBy { get { return ResourceManager.GetString("StartedBy", resourceCulture); } } public static string Subject { get { return ResourceManager.GetString("Subject", resourceCulture); } } public static string Text { get { return ResourceManager.GetString("Text", resourceCulture); } } public static string To { get { return ResourceManager.GetString("To", resourceCulture); } } public static string UploadNew { get { return ResourceManager.GetString("UploadNew", resourceCulture); } } public static string UserNotFound { get { return ResourceManager.GetString("UserNotFound", resourceCulture); } } public static string Views { get { return ResourceManager.GetString("Views", resourceCulture); } } public static string Web { get { return ResourceManager.GetString("Web", resourceCulture); } } public static string WebVisit { get { return ResourceManager.GetString("WebVisit", resourceCulture); } } public static string YourIP { get { return ResourceManager.GetString("YourIP", resourceCulture); } } public static string Birthday { get { return ResourceManager.GetString("Birthday", resourceCulture); } } public static string Contact { get { return ResourceManager.GetString("Contact", resourceCulture); } } public static string Profile { get { return ResourceManager.GetString("Profile", resourceCulture); } } public static string SendNameEmail { get { return ResourceManager.GetString("SendNameEmail", resourceCulture); } } public static string SendNamePM { get { return ResourceManager.GetString("SendNamePM", resourceCulture); } } public static string Unsubscribe { get { return ResourceManager.GetString("Unsubscribe", resourceCulture); } } public static string UnsubscribeFail { get { return ResourceManager.GetString("UnsubscribeFail", resourceCulture); } } public static string UnsubscribeLinkBad { get { return ResourceManager.GetString("UnsubscribeLinkBad", resourceCulture); } } public static string UnsubscribeNote { get { return ResourceManager.GetString("UnsubscribeNote", resourceCulture); } } public static string AddForum { get { return ResourceManager.GetString("AddForum", resourceCulture); } } public static string AddNew { get { return ResourceManager.GetString("AddNew", resourceCulture); } } public static string AllowImages { get { return ResourceManager.GetString("AllowImages", resourceCulture); } } public static string Archived { get { return ResourceManager.GetString("Archived", resourceCulture); } } public static string AreYouSure { get { return ResourceManager.GetString("AreYouSure", resourceCulture); } } public static string Ban { get { return ResourceManager.GetString("Ban", resourceCulture); } } public static string BanRemove { get { return ResourceManager.GetString("BanRemove", resourceCulture); } } public static string Categories { get { return ResourceManager.GetString("Categories", resourceCulture); } } public static string Category { get { return ResourceManager.GetString("Category", resourceCulture); } } public static string CategoryEditTitle { get { return ResourceManager.GetString("CategoryEditTitle", resourceCulture); } } public static string CategoryMoveError { get { return ResourceManager.GetString("CategoryMoveError", resourceCulture); } } public static string CensorReplacementChar { get { return ResourceManager.GetString("CensorReplacementChar", resourceCulture); } } public static string CensorWords { get { return ResourceManager.GetString("CensorWords", resourceCulture); } } public static string Delete { get { return ResourceManager.GetString("Delete", resourceCulture); } } public static string DeleteAndBanButton { get { return ResourceManager.GetString("DeleteAndBanButton", resourceCulture); } } public static string DeleteUserButton { get { return ResourceManager.GetString("DeleteUserButton", resourceCulture); } } public static string Description { get { return ResourceManager.GetString("Description", resourceCulture); } } public static string Down { get { return ResourceManager.GetString("Down", resourceCulture); } } public static string Edit { get { return ResourceManager.GetString("Edit", resourceCulture); } } public static string EditForum { get { return ResourceManager.GetString("EditForum", resourceCulture); } } public static string EditUser { get { return ResourceManager.GetString("EditUser", resourceCulture); } } public static string EmailBan { get { return ResourceManager.GetString("EmailBan", resourceCulture); } } public static string EmailIpBan { get { return ResourceManager.GetString("EmailIpBan", resourceCulture); } } public static string EmailNewOptional { get { return ResourceManager.GetString("EmailNewOptional", resourceCulture); } } public static string EmailUsers { get { return ResourceManager.GetString("EmailUsers", resourceCulture); } } public static string ErrorLog { get { return ResourceManager.GetString("ErrorLog", resourceCulture); } } public static string Forum { get { return ResourceManager.GetString("Forum", resourceCulture); } } public static string ForumAdapter { get { return ResourceManager.GetString("ForumAdapter", resourceCulture); } } public static string ForumHome { get { return ResourceManager.GetString("ForumHome", resourceCulture); } } public static string ForumPermisions { get { return ResourceManager.GetString("ForumPermisions", resourceCulture); } } public static string ForumSettings { get { return ResourceManager.GetString("ForumSettings", resourceCulture); } } public static string ForumsUncat { get { return ResourceManager.GetString("ForumsUncat", resourceCulture); } } public static string GeneralSettings { get { return ResourceManager.GetString("GeneralSettings", resourceCulture); } } public static string IpBan { get { return ResourceManager.GetString("IpBan", resourceCulture); } } public static string IpHistory { get { return ResourceManager.GetString("IpHistory", resourceCulture); } } public static string IsApproved { get { return ResourceManager.GetString("IsApproved", resourceCulture); } } public static string IsSubscribed { get { return ResourceManager.GetString("IsSubscribed", resourceCulture); } } public static string LogErrors { get { return ResourceManager.GetString("LogErrors", resourceCulture); } } public static string Logging { get { return ResourceManager.GetString("Logging", resourceCulture); } } public static string LogMod { get { return ResourceManager.GetString("LogMod", resourceCulture); } } public static string LogSecurity { get { return ResourceManager.GetString("LogSecurity", resourceCulture); } } public static string MinimumTimeBetweenPosts { get { return ResourceManager.GetString("MinimumTimeBetweenPosts", resourceCulture); } } public static string ModerationLog { get { return ResourceManager.GetString("ModerationLog", resourceCulture); } } public static string Move { get { return ResourceManager.GetString("Move", resourceCulture); } } public static string NewUserApprovedWithoutVerification { get { return ResourceManager.GetString("NewUserApprovedWithoutVerification", resourceCulture); } } public static string NewUserImageApprovedWithoutMod { get { return ResourceManager.GetString("NewUserImageApprovedWithoutMod", resourceCulture); } } public static string Parsing { get { return ResourceManager.GetString("Parsing", resourceCulture); } } public static string PasswordNewOptional { get { return ResourceManager.GetString("PasswordNewOptional", resourceCulture); } } public static string PopForumsAdmin { get { return ResourceManager.GetString("PopForumsAdmin", resourceCulture); } } public static string PostsPerPage { get { return ResourceManager.GetString("PostsPerPage", resourceCulture); } } public static string Role { get { return ResourceManager.GetString("Role", resourceCulture); } } public static string Roles { get { return ResourceManager.GetString("Roles", resourceCulture); } } public static string Security { get { return ResourceManager.GetString("Security", resourceCulture); } } public static string SecurityLog { get { return ResourceManager.GetString("SecurityLog", resourceCulture); } } public static string ServerTime { get { return ResourceManager.GetString("ServerTime", resourceCulture); } } public static string Services { get { return ResourceManager.GetString("Services", resourceCulture); } } public static string SessionLength { get { return ResourceManager.GetString("SessionLength", resourceCulture); } } public static string Size { get { return ResourceManager.GetString("Size", resourceCulture); } } public static string SubjectAndBodyNotEmpty { get { return ResourceManager.GetString("SubjectAndBodyNotEmpty", resourceCulture); } } public static string Title { get { return ResourceManager.GetString("Title", resourceCulture); } } public static string TopicsPerPage { get { return ResourceManager.GetString("TopicsPerPage", resourceCulture); } } public static string Up { get { return ResourceManager.GetString("Up", resourceCulture); } } public static string UserAvatarMaxDim { get { return ResourceManager.GetString("UserAvatarMaxDim", resourceCulture); } } public static string UserDeleteWarning { get { return ResourceManager.GetString("UserDeleteWarning", resourceCulture); } } public static string UserImageApproval { get { return ResourceManager.GetString("UserImageApproval", resourceCulture); } } public static string UserImageMaxDim { get { return ResourceManager.GetString("UserImageMaxDim", resourceCulture); } } public static string UserRoles { get { return ResourceManager.GetString("UserRoles", resourceCulture); } } public static string Visible { get { return ResourceManager.GetString("Visible", resourceCulture); } } public static string AddNewForum { get { return ResourceManager.GetString("AddNewForum", resourceCulture); } } public static string Body { get { return ResourceManager.GetString("Body", resourceCulture); } } public static string BodyHtml { get { return ResourceManager.GetString("BodyHtml", resourceCulture); } } public static string ClickToLoadMorePosts { get { return ResourceManager.GetString("ClickToLoadMorePosts", resourceCulture); } } public static string Comment { get { return ResourceManager.GetString("Comment", resourceCulture); } } public static string CommentsOptional { get { return ResourceManager.GetString("CommentsOptional", resourceCulture); } } public static string CreateJunkWordButton { get { return ResourceManager.GetString("CreateJunkWordButton", resourceCulture); } } public static string CreateNewReply { get { return ResourceManager.GetString("CreateNewReply", resourceCulture); } } public static string CreateNewRole { get { return ResourceManager.GetString("CreateNewRole", resourceCulture); } } public static string CreateNewTopic { get { return ResourceManager.GetString("CreateNewTopic", resourceCulture); } } public static string DeleteAllErrors { get { return ResourceManager.GetString("DeleteAllErrors", resourceCulture); } } public static string DeleteJunkWordButton { get { return ResourceManager.GetString("DeleteJunkWordButton", resourceCulture); } } public static string DeleteSelectedRole { get { return ResourceManager.GetString("DeleteSelectedRole", resourceCulture); } } public static string EditPost { get { return ResourceManager.GetString("EditPost", resourceCulture); } } public static string EmailUsersQueued { get { return ResourceManager.GetString("EmailUsersQueued", resourceCulture); } } public static string EndDate { get { return ResourceManager.GetString("EndDate", resourceCulture); } } public static string Event { get { return ResourceManager.GetString("Event", resourceCulture); } } public static string EventTime { get { return ResourceManager.GetString("EventTime", resourceCulture); } } public static string FavoriteMustBeLoggedIn { get { return ResourceManager.GetString("FavoriteMustBeLoggedIn", resourceCulture); } } public static string FavoritesDontHave { get { return ResourceManager.GetString("FavoritesDontHave", resourceCulture); } } public static string FavoriteTopics { get { return ResourceManager.GetString("FavoriteTopics", resourceCulture); } } public static string ForumMoveError { get { return ResourceManager.GetString("ForumMoveError", resourceCulture); } } public static string ForumPermissionInstructions { get { return ResourceManager.GetString("ForumPermissionInstructions", resourceCulture); } } public static string FromEmailAddress { get { return ResourceManager.GetString("FromEmailAddress", resourceCulture); } } public static string ID { get { return ResourceManager.GetString("ID", resourceCulture); } } public static string IncludeSignature { get { return ResourceManager.GetString("IncludeSignature", resourceCulture); } } public static string IndexingInterval { get { return ResourceManager.GetString("IndexingInterval", resourceCulture); } } public static string IP { get { return ResourceManager.GetString("IP", resourceCulture); } } public static string IsRunning { get { return ResourceManager.GetString("IsRunning", resourceCulture); } } public static string JunkWords { get { return ResourceManager.GetString("JunkWords", resourceCulture); } } public static string MarkForumRead { get { return ResourceManager.GetString("MarkForumRead", resourceCulture); } } public static string Message { get { return ResourceManager.GetString("Message", resourceCulture); } } public static string Millseconds { get { return ResourceManager.GetString("Millseconds", resourceCulture); } } public static string NamePosts { get { return ResourceManager.GetString("NamePosts", resourceCulture); } } public static string Optional { get { return ResourceManager.GetString("Optional", resourceCulture); } } public static string PermRoles { get { return ResourceManager.GetString("PermRoles", resourceCulture); } } public static string PostID { get { return ResourceManager.GetString("PostID", resourceCulture); } } public static string PostingRoles { get { return ResourceManager.GetString("PostingRoles", resourceCulture); } } public static string PostNewTopic { get { return ResourceManager.GetString("PostNewTopic", resourceCulture); } } public static string PreviewTopic { get { return ResourceManager.GetString("PreviewTopic", resourceCulture); } } public static string Remove { get { return ResourceManager.GetString("Remove", resourceCulture); } } public static string RemoveAll { get { return ResourceManager.GetString("RemoveAll", resourceCulture); } } public static string SendingInterval { get { return ResourceManager.GetString("SendingInterval", resourceCulture); } } public static string SmtpPassword { get { return ResourceManager.GetString("SmtpPassword", resourceCulture); } } public static string SmtpPort { get { return ResourceManager.GetString("SmtpPort", resourceCulture); } } public static string SmtpServer { get { return ResourceManager.GetString("SmtpServer", resourceCulture); } } public static string SmtpUser { get { return ResourceManager.GetString("SmtpUser", resourceCulture); } } public static string StartDate { get { return ResourceManager.GetString("StartDate", resourceCulture); } } public static string SubmitNewTopic { get { return ResourceManager.GetString("SubmitNewTopic", resourceCulture); } } public static string SubmitReply { get { return ResourceManager.GetString("SubmitReply", resourceCulture); } } public static string TopicID { get { return ResourceManager.GetString("TopicID", resourceCulture); } } public static string Type { get { return ResourceManager.GetString("Type", resourceCulture); } } public static string UseEsmtpCred { get { return ResourceManager.GetString("UseEsmtpCred", resourceCulture); } } public static string UserID { get { return ResourceManager.GetString("UserID", resourceCulture); } } public static string UserIDTarget { get { return ResourceManager.GetString("UserIDTarget", resourceCulture); } } public static string UserImageApprovalNotReq { get { return ResourceManager.GetString("UserImageApprovalNotReq", resourceCulture); } } public static string UseSsl { get { return ResourceManager.GetString("UseSsl", resourceCulture); } } public static string ViewingRoles { get { return ResourceManager.GetString("ViewingRoles", resourceCulture); } } public static string Close { get { return ResourceManager.GetString("Close", resourceCulture); } } public static string CloseOnReply { get { return ResourceManager.GetString("CloseOnReply", resourceCulture); } } public static string FavoriteMake { get { return ResourceManager.GetString("FavoriteMake", resourceCulture); } } public static string FavoriteRemove { get { return ResourceManager.GetString("FavoriteRemove", resourceCulture); } } public static string Link { get { return ResourceManager.GetString("Link", resourceCulture); } } public static string Moderator { get { return ResourceManager.GetString("Moderator", resourceCulture); } } public static string NameAvatar { get { return ResourceManager.GetString("NameAvatar", resourceCulture); } } public static string NameLastEdit { get { return ResourceManager.GetString("NameLastEdit", resourceCulture); } } public static string Open { get { return ResourceManager.GetString("Open", resourceCulture); } } public static string Pin { get { return ResourceManager.GetString("Pin", resourceCulture); } } public static string PostReply { get { return ResourceManager.GetString("PostReply", resourceCulture); } } public static string Quote { get { return ResourceManager.GetString("Quote", resourceCulture); } } public static string RecentTopics { get { return ResourceManager.GetString("RecentTopics", resourceCulture); } } public static string Reply { get { return ResourceManager.GetString("Reply", resourceCulture); } } public static string ShowMorePosts { get { return ResourceManager.GetString("ShowMorePosts", resourceCulture); } } public static string ShowPreviousPosts { get { return ResourceManager.GetString("ShowPreviousPosts", resourceCulture); } } public static string Subscribe { get { return ResourceManager.GetString("Subscribe", resourceCulture); } } public static string Undelete { get { return ResourceManager.GetString("Undelete", resourceCulture); } } public static string Unpin { get { return ResourceManager.GetString("Unpin", resourceCulture); } } public static string Update { get { return ResourceManager.GetString("Update", resourceCulture); } } public static string Archive { get { return ResourceManager.GetString("Archive", resourceCulture); } } public static string NewPM { get { return ResourceManager.GetString("NewPM", resourceCulture); } } public static string NoResults { get { return ResourceManager.GetString("NoResults", resourceCulture); } } public static string Send { get { return ResourceManager.GetString("Send", resourceCulture); } } public static string Unarchive { get { return ResourceManager.GetString("Unarchive", resourceCulture); } } public static string ViewArchivedMessages { get { return ResourceManager.GetString("ViewArchivedMessages", resourceCulture); } } public static string DisplayName { get { return ResourceManager.GetString("DisplayName", resourceCulture); } } public static string Error { get { return ResourceManager.GetString("Error", resourceCulture); } } public static string ErrorSettingUpDb { get { return ResourceManager.GetString("ErrorSettingUpDb", resourceCulture); } } public static string ForumReady { get { return ResourceManager.GetString("ForumReady", resourceCulture); } } public static string ForumTitle { get { return ResourceManager.GetString("ForumTitle", resourceCulture); } } public static string GoAdmin { get { return ResourceManager.GetString("GoAdmin", resourceCulture); } } public static string InvalidLink { get { return ResourceManager.GetString("InvalidLink", resourceCulture); } } public static string NoDataConnection { get { return ResourceManager.GetString("NoDataConnection", resourceCulture); } } public static string PopForumsSetup { get { return ResourceManager.GetString("PopForumsSetup", resourceCulture); } } public static string SetupCantConnect { get { return ResourceManager.GetString("SetupCantConnect", resourceCulture); } } public static string SetupConnSuccess { get { return ResourceManager.GetString("SetupConnSuccess", resourceCulture); } } public static string SetupDatabase { get { return ResourceManager.GetString("SetupDatabase", resourceCulture); } } public static string SetupFirstUser { get { return ResourceManager.GetString("SetupFirstUser", resourceCulture); } } public static string SubscribedTopics { get { return ResourceManager.GetString("SubscribedTopics", resourceCulture); } } public static string SubscribeLoggedIn { get { return ResourceManager.GetString("SubscribeLoggedIn", resourceCulture); } } public static string SubscribeNone { get { return ResourceManager.GetString("SubscribeNone", resourceCulture); } } public static string Success { get { return ResourceManager.GetString("Success", resourceCulture); } } public static string UnsubscribeTopic { get { return ResourceManager.GetString("UnsubscribeTopic", resourceCulture); } } public static string ArchivedNewPosts { get { return ResourceManager.GetString("ArchivedNewPosts", resourceCulture); } } public static string Closed { get { return ResourceManager.GetString("Closed", resourceCulture); } } public static string ClosedPinned { get { return ResourceManager.GetString("ClosedPinned", resourceCulture); } } public static string DeleteTopic { get { return ResourceManager.GetString("DeleteTopic", resourceCulture); } } public static string First { get { return ResourceManager.GetString("First", resourceCulture); } } public static string ForgotPasswordEmail { get { return ResourceManager.GetString("ForgotPasswordEmail", resourceCulture); } } public static string ForgotPasswordSubject { get { return ResourceManager.GetString("ForgotPasswordSubject", resourceCulture); } } public static string ForumNoPost { get { return ResourceManager.GetString("ForumNoPost", resourceCulture); } } public static string ForumNoView { get { return ResourceManager.GetString("ForumNoView", resourceCulture); } } public static string LessThanMinute { get { return ResourceManager.GetString("LessThanMinute", resourceCulture); } } public static string LoginToPost { get { return ResourceManager.GetString("LoginToPost", resourceCulture); } } public static string MinutesAgo { get { return ResourceManager.GetString("MinutesAgo", resourceCulture); } } public static string More { get { return ResourceManager.GetString("More", resourceCulture); } } public static string NewPosts { get { return ResourceManager.GetString("NewPosts", resourceCulture); } } public static string NewPostsClosed { get { return ResourceManager.GetString("NewPostsClosed", resourceCulture); } } public static string NewPostsClosedPinned { get { return ResourceManager.GetString("NewPostsClosedPinned", resourceCulture); } } public static string NewPostsPinned { get { return ResourceManager.GetString("NewPostsPinned", resourceCulture); } } public static string Next { get { return ResourceManager.GetString("Next", resourceCulture); } } public static string NoNewPosts { get { return ResourceManager.GetString("NoNewPosts", resourceCulture); } } public static string NotLoggedIn { get { return ResourceManager.GetString("NotLoggedIn", resourceCulture); } } public static string OneMinuteAgo { get { return ResourceManager.GetString("OneMinuteAgo", resourceCulture); } } public static string Pinned { get { return ResourceManager.GetString("Pinned", resourceCulture); } } public static string PMCreateWarnings { get { return ResourceManager.GetString("PMCreateWarnings", resourceCulture); } } public static string PostEmpty { get { return ResourceManager.GetString("PostEmpty", resourceCulture); } } public static string PostWait { get { return ResourceManager.GetString("PostWait", resourceCulture); } } public static string Previous { get { return ResourceManager.GetString("Previous", resourceCulture); } } public static string RegisterEmailSubject { get { return ResourceManager.GetString("RegisterEmailSubject", resourceCulture); } } public static string RegisterEmailThankYou { get { return ResourceManager.GetString("RegisterEmailThankYou", resourceCulture); } } public static string RegisterEmailThankYouVerify { get { return ResourceManager.GetString("RegisterEmailThankYouVerify", resourceCulture); } } public static string SettingsSaved { get { return ResourceManager.GetString("SettingsSaved", resourceCulture); } } public static string TodayTime { get { return ResourceManager.GetString("TodayTime", resourceCulture); } } public static string TopicNotExist { get { return ResourceManager.GetString("TopicNotExist", resourceCulture); } } public static string YesterdayTime { get { return ResourceManager.GetString("YesterdayTime", resourceCulture); } } public static string Loading { get { return ResourceManager.GetString("Loading", resourceCulture); } } public static string Voted { get { return ResourceManager.GetString("Voted", resourceCulture); } } public static string VoteUpPublishMessage { get { return ResourceManager.GetString("VoteUpPublishMessage", resourceCulture); } } public static string ScoringGame { get { return ResourceManager.GetString("ScoringGame", resourceCulture); } } public static string Awards { get { return ResourceManager.GetString("Awards", resourceCulture); } } public static string EventDefinitions { get { return ResourceManager.GetString("EventDefinitions", resourceCulture); } } public static string AwardDefinitions { get { return ResourceManager.GetString("AwardDefinitions", resourceCulture); } } public static string ManualEvent { get { return ResourceManager.GetString("ManualEvent", resourceCulture); } } public static string None { get { return ResourceManager.GetString("None", resourceCulture); } } public static string ActivityFeed { get { return ResourceManager.GetString("ActivityFeed", resourceCulture); } } public static string NewReplyPublishMessage { get { return ResourceManager.GetString("NewReplyPublishMessage", resourceCulture); } } public static string NewPostPublishMessage { get { return ResourceManager.GetString("NewPostPublishMessage", resourceCulture); } } public static string ExternalLogins { get { return ResourceManager.GetString("ExternalLogins", resourceCulture); } } public static string UseExistingForumAccount { get { return ResourceManager.GetString("UseExistingForumAccount", resourceCulture); } } public static string ExpiredLogin { get { return ResourceManager.GetString("ExpiredLogin", resourceCulture); } } public static string ExternalLoginsDisabled { get { return ResourceManager.GetString("ExternalLoginsDisabled", resourceCulture); } } public static string NoExternalLoginsRegistered { get { return ResourceManager.GetString("NoExternalLoginsRegistered", resourceCulture); } } public static string DeletePermanently { get { return ResourceManager.GetString("DeletePermanently", resourceCulture); } } public static string Preview { get { return ResourceManager.GetString("Preview", resourceCulture); } } public static string AskAQuestion { get { return ResourceManager.GetString("AskAQuestion", resourceCulture); } } public static string Answers { get { return ResourceManager.GetString("Answers", resourceCulture); } } public static string QuestionAnswered { get { return ResourceManager.GetString("QuestionAnswered", resourceCulture); } } public static string ChooseAnswer { get { return ResourceManager.GetString("ChooseAnswer", resourceCulture); } } public static string PostAnswer { get { return ResourceManager.GetString("PostAnswer", resourceCulture); } } public static string SubmitAnswer { get { return ResourceManager.GetString("SubmitAnswer", resourceCulture); } } public static string AppRestartRequired { get { return ResourceManager.GetString("AppRestartRequired", resourceCulture); } } public static string SearchError { get { return ResourceManager.GetString("SearchError", resourceCulture); } } public static string BotError { get { return ResourceManager.GetString("BotError", resourceCulture); } } public static string CloseOldTopics { get { return ResourceManager.GetString("CloseOldTopics", resourceCulture); } } public static string Days { get { return ResourceManager.GetString("Days", resourceCulture); } } public static string PrivateForum { get { return ResourceManager.GetString("PrivateForum", resourceCulture); } } public static string AccountNotVerified { get { return ResourceManager.GetString("AccountNotVerified", resourceCulture); } } public static string SelectText { get { return ResourceManager.GetString("SelectText", resourceCulture); } } public static string Notifications { get { return ResourceManager.GetString("Notifications", resourceCulture); } } public static string NewReplyNotification { get { return ResourceManager.GetString("NewReplyNotification", resourceCulture); } } public static string Award { get { return ResourceManager.GetString("Award", resourceCulture); } } public static string VoteUpNotification { get { return ResourceManager.GetString("VoteUpNotification", resourceCulture); } } public static string QuestionAnsweredNotification { get { return ResourceManager.GetString("QuestionAnsweredNotification", resourceCulture); } } public static string AutoFollow { get { return ResourceManager.GetString("AutoFollow", resourceCulture); } } public static string MarkAllRead { get { return ResourceManager.GetString("MarkAllRead", resourceCulture); } } public static string UploadImage { get { return ResourceManager.GetString("UploadImage", resourceCulture); } } public static string Ignored { get { return ResourceManager.GetString("Ignored", resourceCulture); } } public static string Ignore { get { return ResourceManager.GetString("Ignore", resourceCulture); } } public static string IgnoreList { get { return ResourceManager.GetString("IgnoreList", resourceCulture); } } } } ================================================ FILE: src/PopForums/Resources/Resources.de.resx ================================================  text/microsoft-resx 2.0 System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 Account Ihr Account wurde erstellt. Ihr Account wurde erstellt und Sie können nun das Forum nutzen. Um das Forum zu nutzen bestätigen Sie bitte den Link in der e-Mail (wurde an Sie versendet). Ihr Account wurde überprüft. Sie sind eingeloggt. Erscheint, freigewählt, so wie Sie es möchten. mindestens 6 Zeichen by Kontext: "Letzte Nachricht:12 Uhr by Jeff Account erstellen Account erstellen Profil editieren E-mail Die Bestätigungs e-mail konnte nicht gesendet werden: Favoriten Sorry, Sie haben keine Berechtigung für diese Ansicht. Forum Ich habe die Nutzungbedingungen gelesen und akzeptiert. Letzte Login Logout Alle gelesenen Foren markieren Mein Geburtstag ist am oder vor {0} Wo {0} ist ein Datum Name Möchten Sie ein bestehendes account bestätigen? Seite nicht gefunden Passwort Einträge Private Nachrichten Neueste Registrierte Mitglieder Passwort wiederholen Suche Eintragen (Abbonieren) in die Mailing-Liste Abbonements Nutzungsbedingungen Zeitzone Themen Gesamt Gesamte Einträge Alle Themen EU-Zeitverschiebung nutzen User Online Für eine Registrierung müssen Sie 13 Jahre oder älter sein Sie müssen die Teilnahmebedingungen akzeptieren Geben Bitte Sie Ihr Passwort erneut ein Name erforderlich Dieser Name ist schon vergeben E-mail erforderlich Gültige e-Mail Adresse erforderlich Diese e-Mail Adresse ist schon vergeben. Diese e-Mail Adresse wurde gesperrt. Ihre IP-Adresse wurde gesperrt Das Passwort stimmt nicht überein. Account bearbeiten Profil bearbeiten Geben Sie bitte Ihren Überprüfungs-Code ein. Startseite Sie müssen registriert und eingeloggt sein um Ihren Account bearbeiten zu können. Kein Teilnehmer mit dieser e-Mail gefunden. Neue e-Mail mit Code senden Ihre bestätigte e-Mail wurde versendet. Bestätigung fehlerhaft Wenn Sie einen neuen Bestätigungs-Code benötigen, tragen Sie Ihre e-Mail hier ein. Sorry, der Bestätigungs-Link oder eingegebene Code ist fehlerhaft. Überprüfen Sie Ihr Konto. Bestätigungs-Code Avatar Avatar löschen e-Mail erneuern e-Mail erneuern und Code senden Das wechseln der e-Mail Adresse wird einen neuen Bestätigungs-Code auslösen der an die neue e-Mail Adresse gesendet wird. Bevor Sie neue Einträge im Forum vornehmen können, müssen Sie den neuen Code mit der neuen e-Mail Adresse bestätigen. Passwort ändern Ändern Sie bitte Ihre e-Mail oder Ihr Passwort Geburtsdatum Details Ihre e-Mail wurde gewechselt Neue e-Mail Adresse Neue e-Mail Adresse wiederholen e-Mail nicht gefunden Ihre e-Mail wurde gesendet Diese e-Mail Adresse stimmt nicht überein e-Mail User Normal-Text Geben Sie die e-Mail Adresse ein die im Account hinterlegt ist, um eine Anleitung zur Erneuerung ihres Passwortes zu erhalten. Die Anleitung wurde zu Ihrer e-Mail Adresse gesendet Passwort vergessen Passwort vergessen? Hide vanity in Kontext: gestarted von Jeff im Hauptforum Verbinden Ort Sie sind eingeloggt. Falsche e-Mail oder Passwort Foto-Verwaltung Fotos verwalten Neues Passwort gespeichert Nicht registriert? Altes Passwort nicht korrekt Optionen Neues Passwort Neues Passwort wiederholen Altes Passwort Passwort erneuern Dies ist kein gültiger Passwort-Erneuerungs-Link. Ihr neues Passwort ist aktiv und Sie sind eingeloggt. Die Passwort Erneuerung war erfolgreich Foto Foto löschen (dieses Bild wurde noch nicht durch den Administrator freigegeben) Profil updaten Erinnnern Sie mich? Antworten Speichern e-Mail senden Private Nachricht senden Zeigt dein Profil (aber nicht die e-Mail) Signatur Gestartet von Thema Text nach neu Upload Teilnehmer nicht gefunden Views Web Besuchen Sie unsere Website Ihre IP-Adresse Geburtstag Kontakt Profil {0} hat eine Nachricht gesendet {0} is a name Send {0} eine private Nachricht {0} ist ein Name Abbestellen Abbestellung fehlerhaft Der Link ist nicht gültig. Ihre Abbestellung (Austragung) war erfolgreich. Sie können sich nun in Ihren Account neu einloggen. neues Forum Neu Bilder erlaubt Archiviert Sind Sie sicher? Sperren Sperrung verschieben Kategorien Kategorie Titel der Kategorie ändern Es gab einen unbekannten Fehler, beim Versuch die Kategorie zu verschieben. ersetzte zensierte Buchstaben Zensierte Wörter Löschen Lösche und sperre Teilnehmer Lösche Teilnehmer Beschreibung Abwärts Bearbeiten Forum bearbeiten Teilnehmer bearbeiten e-Mail sperren e-Mail/IP sperren Neue e-Mail (optional) e-Mail Teilnehmer ErrorLog Forum Forum Adapter (fakultativ, verwenden "Namespace.Type, AssemblyName") Forum Home Forum Erlaubnis Forum Einstellungen Unkategorisiertes Foren Haupteinstellungen IP gesperrt IP History Ist geprüft Ist eingetragen (abboniert) Log Fehler Logging Log moderation Log Sicherheits Aktionen Minimum (Sekunden) zwischen den Posts Moderation Log Verschieben Neues Mitglied wurde genehmigt ohne Überprüfung Neues Mitglieder-Bild wurde genehmigt ohne Moderation Saztgliederung Neues Passwort (optional) POP Forums Administration Posts pro Seite Rolle Rollen Sicherheit Security Log Server Zeit Services Session Zeit (Minuten) Size Thema und Beschreibung dürfen nicht leer sein. Titel Themen pro Seite Up Maximal Grösse des Mitglied-Avatars WARNUNG! Das Löschen des Accounts ist unwiederruflich Bild des Mitglieds geprüft Maximale Bild-Größe des Mitglieds Mitglieder Rollen Sichtbar Neues Forum hinzufügen Body HTML Body Hier klicken um neuere Posts vor dem Antworten zu laden. Kommentar Kommentar (optional) Neues Junk-Wort erstellen Antwort-Erstellen Neue Rolle erstellen Neues Thema erstellen Alle Fehler löschen Ausgewähltes Junk-Wort löschen Ausgewählte Rolle löschen Eintrag ändern e-Mail in der Warteschlange zu abbonierten (eingetragenen) Mitgliedern. Ende des Datums Event Event-Zeit Sie müssen eingeloggt sein um die Favoriten-Themen zu sehen. Sie haben keine Favoriten-Themen. Favoriten Themen Es gab einen unbekannten Fehler, beim Versuch das Forum zu verschieben. Um den Eintrag zu verschieben oder die Restriktionen anzuzeigen, verschiebe alle Rollen von der passenden Box. Von der e-Mail Adresse ID mit Signatur Indizierter Intervall IP Ist am Laufen Junk-Wort markiere Forum gelesen Nachricht Millisekunden {0}s posts {0} ist ein Name (fakultativ) Administrator und Moderator sind permanente Rollen, und kann nicht gelöscht werden. PostID gepostete Rollen Neue Rolle posten Thema-Vorschau Verb-Vorschau, nicht Adjektive Verschieben Alles verschieben Interval senden SMTP Passwort Port (Standardwert ist 25) SMTP Server SMTP User Tag starten Neues Thema hinzufügen Anwort miteilen AnwortID Type Benutzen Sie ESMTP für Bescheinigungen UserID Ziel TeilnehmerID upgeloadete Mitglieder-Bilder benötigen im Moment keine Überprüfung Use SSL angezeigte Rollen Schließen Schließen auf Antwort Favorit erstellen Verschieben von Favorites Link Moderator {0}s avatar {0} ist ein Name zuletzt bearbeitet von {0} {0} ist ein Name Öffnen Pin Anwort senden Quote neueste Themen Antwort Mehr Einträge anzeigen zeige vorhergende Einträge Abbonieren (Eintragen) Wiederherstellen Losheften Update Archiv Neue Private Nachricht Keine Ergebnisse gefunden No results found Senden dearchivieren Zeige archivierte Nachrichten Namen einblenden Fehler Es gab einen Fehler beim Einrichten der Datenbank. Ihr Forum ist fertig. Forum Titel Gehen Sie in den Admin-Bereich um das Einrichten des Forum abzuschließen. Dieser Link ist nicht mehr gültig. Keine Datenverbindung POP Forums Setup Das Setup hat keine Verbindung zum Data Store. Bitte überprüfen Sie den Connection String und Configuration. Eine Datenbankverbindung war erfolgreich. Bitte füllen Sie die folgenden Werte aus, um zu starten. Setup der Datenbank. Setup das erste Mitglied des Forms (wird Admin- und Moderator-Rechte bewilligen) Setup your first user (will be granted admin and moderator rights) Abbonierte Themen Sie müssen eingeloggt sein um abbonierte Themen zu sehen. Sie sind nicht abboniert zu einem Thema. Das war erfolgreich! {0} ist ausgetragen von {1} {0} ist ein Name, {1} ist ein Überschriften Titel Archiviert mit neuen Einträgen (Posts) geschlossenes Thema geschlossen, verstiftetes (pinned) Thema Lösche Thema Erste Um Ihr Passwort zu erneuern auf {0}, folgen Sie bitte diesem Link :{3}{1}{3}{3}{2} {0}=title of forum, {1}=email link, {2}=signature, {3}=new line Dein Passwort zurücksetzen Anleitung für {0} {0} ist Name des Forums Du hast keine Erlaubnis in dieses Forum zu posten. Du hast keine Erlaubnis dieses Forum zu besuchen. Weniger als eine Minute zurück. Sie müssen eingeloggt sein um zu posten Vor {0} Minuten {0} ist eine Nummer Mehr Neue Posts Neue Posts, geschlossenes Thema Neue Posts, geschlossenes; gemerktes Thema Neue Posts, gemerktes Thema Nächstes Keine neuen Beiträge Sie sind nicht eingeloggt 1 Minute zurück gemerktes Thema Muss beinhalten ein Nachricht und zuletzt ein Mitglied. Ein leerer Beitrag kann nicht erstellt werden. Du musst {0} Sekunden zwischen einem Beitrageintrag warten damit der letzte Eintrag nicht doppelt erscheint. {0} is a number for seconds Vorheriger Forum Registrierungen {0} {0} ist der Name des Forums Danke für die Registrierung mit {0}.{2}{2}{1} {0}=name of forum, {1}=forum link, {2}=new line Danke für die Registrierung mit {0}. Bitte folgen Sie diesem Link um Ihre Registrierung zu bestätigen:{5}{5}{1} {5}{5} Wenn Sie diesem Link nicht folgen können, rufen Sie bitte die nachfolgende Seite auf, kopieren den Bestätigungs-Code und fügen ihn ein: {5}{5}Link: {2} {5}Code: {3} {5}{5}{4} {0}=name of forum, {1}=auth link, {2}=verify link, {3}=auth code, {4}=sig, {5}new line Einstellungen sichern Heute, {0} {0} ist eine Zeit wie 1:43pm{0} is a time like 1:43pm Diese Thema existiert nicht Gestern, {0} {0} a time like 1:43pm wird geladen Voted <a href="{0}">{1}</a> voted für einen Beitrag in dem Thema: <a href="{2}">{3}</a> {0} is uri to user, {1} is user name, {2} is uri to post, {3} is topic name Keiner Scoring-Spiel Aktivitäts-Feed <a href="{0}">{1}</a> einen Beitrag in dem Thema: <a href="{2}">{3}</a> {0} is uri to user, {1} is user name, {2} is uri to post, {3} is topic name <a href="{0}">{1}</a> begann ein neues Thema: <a href="{2}">{3}</a> {0} is uri to user, {1} is user name, {2} is uri to topic{3} is topic name Externe Logins Verwenden Sie vorhandene Forum angemeldet Das Login ist abgelaufen oder nicht gültig Externe Logins sind deaktiviert Sie haben keine externen Logins registriert Dauerhaft löschen Vorschau Stellen Sie eine Frage Antworten Wählen Sie Antwort Beitrag Antwort <a href="{0}">{1}</a> wählte eine Antwort auf die Frage: <a href="{2}">{3}</a> {0} is uri to user, {1} is user name, {2} is uri to post, {3} is topic name Antwort abschicken App Neustart erforderlich Es ist ein Suchfehler aufgetreten Schließen Sie alte Themen Tage Privates Forum Konto nicht verifiziert Text auswählen <b>{0}</b> einen Beitrag in dem Thema: <b>{1}</b> <b>{0}</b> voted für einen Beitrag in dem Thema: <b>{1}</b> <b>{0}</b> wählte eine Antwort auf die Frage: <b>{1}</b> Folgen Sie automatisch Themen, auf die Sie antworten markiere alles Gelesene Bild hochladen Ignoriert Ignorierliste Ignorieren ================================================ FILE: src/PopForums/Resources/Resources.es.resx ================================================  text/microsoft-resx 2.0 System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 Cuenta Cuenta Creada Su cuenta fué creada, ya puede usar el foro. Revise su e-mail le ha sido enviado un link de verificación para usar el foro. Su cuenta ha sido verificada. Ud. ya está registrado, y tiene una cuenta válida. (como ud. quiere que aparezca) (mínimo 6 caracteres) por context: "Last post: 12am by Jeff" Crear cuenta Crear una cuenta Editar su perfil E-mail Hubo un problema enviando el e-mail de verificación: Favoritos Lo sentimos, no tiene permisos para ver esto. Foros He leido y acepto los términos de servicio. Último Ingreso Salida Marcar todos como leidos Mi cumpleaños es antes de {0} where {0} is a date Nombre Necesita verificar una cuenta existente? Página no encontrada Clave Mensajes Mensajes Privados Recientes Usuarios registrados Reingrese la clave Buscar Suscribirse a la lista de correo Suscripciones Términos de servicio Zona Horaria Tópicos Total Total mensajes Total topicos Use el horario de verano de N.America/Europa Usuarios en línea Debe ser mayor de 13 años para registrarse. Debe aceptar los términos de servicio Reingrese su clave Nombre requerido Ese nombre ya ha sido usado E-mail requerido Se requiere un e-mail válido Ese e-mail ya ha sido usado Ese e-mail ha sido prohibido Su dirección IP ha sido prohibida La clave debe coincidir con el campo anterior Editar Cuenta Editar Perfil Por favor ingrese su codigo de verificación Página Inicial de Foro Ud. debe estar registrado y haber ingresado para editar su cuenta. No se encontro un uuario con ese e-mail Enviar e-mail con un nuevo código. Su e-mail de verificación ha sido enviado. Falla en la Verificación Si necesita un nuevo código de verificación, ingrese su e-mail aqui Lo sentimos, el link de verificación o el código que ingreso son inválidos. Verificar Cuenta Verificar Código Avatar Borrar este avatar Cambiar e-mail Cambiar e-mail y enviar código El cambio de el e-mail requiere que se genere un nuevo codigo de verificación y que se envie al nuevo e-mail. No podrá colocar mensajes hasta que no verifique su e-mail con el nuevo código. Cambiar Clave Cambiar su e-mail o su clave Fecha de Nacimiento Detalles Su e-mail fué cambiado Nuevo e-mail Reingrese el nuevo e-mail E-mail no encontrado Su e-mail ha sido enviado Las direcciones de e'mail no coinciden E-mail de Usuario Forzar recuadros de texto planos. Ingrese el e-mail asociado a su cuenta para recibir instrucciones para resetear su clave: Instrucciones enviadas a su e-mail Clave olvidada Olvidó su clave? Ocultar Vanity en context: started by Jeff in General Forum Unido(s) Ubicación Ud. ya ha ingresado. E-mail o clave erróneos Administrar fotos Administrar sus fotos Nueva clave grabada No está registrado? Clave antigua incorrecta Opciones Nueva clave Reingrese nueva clave Clave anterior Resetear clave Este no es un vinculo válido para resetear la clave. Su nueva clave ya está activa, ya ha ingresado al foro. Reseteo de clave satisfactorio Foto Borrar esta foto (esta imagen aún no ha sido aprobada por un administrador) Perfil actualizado Recordarme? Respuestas Grabar Enviar E-mail Enviar Mensaje Privado Mostrar los detalles de su perfil (pero no su e-mail) Firma Iniciado por Asunto Texto Para Crgar nuevo Usuario no encontrado Vistas Web Visitar nuestro sitio Web Su direccion IP Cumpleaños Contacto Perfil Enviar a {0} un e-mail {0} is a name Enviar a {0} un mensaje privado {0} is a name Cancelar suscripción Falla en cancelación de suscripción El enlace que ud. siguió no es válido. Su solicitud de cancelación de suscripción ha sido procesada, Puede suscribisrse de nuevo ingresando y visitando la página de Cuenta. Adicionar Foro Adicionar Nuevo Permitir Imégenes Archivado Está seguro? Bloquear Remover Bloqueo Categorias Categoría Editar Título de Categoría Se produjo un error desconocido al intentar mover la categoría Caracter de reemplazo de Censura Palabras Censuradas Borrar Borrar y Bloquear Usuario Borrar Usuario Descripción Abajo Editar Editar Foro Editar Usuario Bloquear E-mail Bloquear E-mail/IP Nuevo e-mail (opcional) Enviar E-mail a Usuarios Bitácora de Errores Foro Adaptador del Foro (opcional, use "Namespace.Type, AssemblyName") Página Principal del Foro Permisos del Foro Configuración del Foro Foros sin categoría Configuración General Bloquear IP Historico de IP Aprobado Suscrito Registrar errores Ingresando Moderar Registro Acciones de Seguridad del Registro Tiempo Mínimo entre mensajes (segundos) Moderar Registro Mover Nuevo usuario aprobado sin verificación Nueva imagen de usuario aprobada sin moderación. Analizando Nueva Clave (optional) Administracion de foros POP Mensajes por Página Rol Roles Seguridad Registro de Seguridad Hora del Servidor Servicios Largo de la Sesión (minutos) Tamaño El asunto y el detalle no deben ir vacios Titulo Tópicos por página Arriba Dimensiones máximas del Avatar PRECAUCION! La eliminacin de una cuenta es permanente. Aprobación de imagen de usuario Dimensiones máximas de imagen de usuario Roles de Usuario Visible Crear nuevo Foro Detalle Detalle HTML Click para leer nuevos mensajes antes de responder Comentario Comentarios (opcional) Crear nueva palabra "Basura" Responder Crear nuevo Rol Crear su nuevo Tópico Eliminar todos los errores Eliminar palabra "Basura" seleccionada Eliminar Rol seleccionado Editar Mensaje E-mail en cola para usuarios suscritos.. Fecha final Evento Hora del Evento Debe haber ingresado para ver tópicos favoritos. No tiene tópicos favoritos. Tópicos Favoritos Se presento un error al intentar mover el foro Para remover restricciones de mensajes y visibilidad, remueva los roles de la lista apropiada. E-mail de origen ID Incluir firma Intervalo de Indice IP Ejecutando Palabras "Basura" Marcar foro como leido Mensaje Milisegundos Mensajes de {0} {0} is a name (opcional) Admin y Moderator son roles permanentes y no pueden ser eliminados. ID de Mensaje Roles de Publicación Publicar un nuevo tópico Previsualizar tópico preview the verb, not adjective Borrar Borrar Todo Intervalo de Envios Clave de SMTP Puerto (default : 25) Servidor SMTP Usuario SMTP Fecha de Inicio Enviar nuevo tópico Enviar respuesta ID de Tópico Tipo Use ESMTP para las credenciales ID de Usuario ID de usuario de destino Las imagenes cargadas actualmente no requieren aprobación Usar SSL Roles de Visivilidad Cerrar Cerrar ante una respuesta Marcar como favorito Quitar de Favorites Enlace Moderador Avetar de {0} {0} is a name Editado por {0} {0} is a name Abrir Anclar Publicar una respuesta Cita Tópicos Recientes Responder Mostrar más mensajes Mostrar mensajes anteriores Subscribir Recuperar Desanclar Actualizar Archivar Nuevo mensaje privado No se encontraron resultados Enviar Desarchivar Ver mensajes archivados Nombre para mostrar Error Se presento un error al configurar la base de datos Su foro está listo. Titulo del Foro Vaya a la seccion de Administración para terminar de configurar su foro. Ese enlace ya no es válido No hay conección de datos Configuración de Foros POP El sistema no puede conectarse a la base de datos. Por favor verifique su cadena de conección y su configuración Conección a la base de datos satisfactoria. Por favor ingrese los siguientes valores para empezar. Configure la base de datos Configura su primer usurio (le serán asignados privilegios de Admin y Moderator) Tópicos suscritos Debe haber ingresado para ver tópicos suscritos. No está suscrito a ningún tópico. Finalizado con éxito! {0} ya no está suscrito a {1} {0} is a name, {1} is a topic title Archivado con nuevos mensajes Tópico cerrado Tópico anclado, ha sido cerrado. Eliminar tópico Primero Para resetear su clave en {0}, por favor vaya al siguiente enlace:{3}{1}{3}{3}{2} {0}=title of forum, {1}=email link, {2}=signature, {3}=new line Instrucciones de reseteo de clave para {0} {0} is name of forum No tiene permisos para publicar en este foro. No tiene permisos para ver este foro Hace menos de un minuto Debe haber ingresado para publicar. Hace {0} minutos {0} is a number Mas Nuevos mensajes Nuevos mensajes, tópico cerrado. Nuevos Mensajes, tópicos cerrados y anclados. Nuevos mensajes, tópicos anclados. Siguiente No hay nuevo mensajes No ha ingresado al sistema. Hace 1 minuto Tópico anclado Debe incluir mensaje y al menos un usuario. No se puede crear un mensaje vacio. Debe esperar {0} entre mensajes, y evita repetir el mensaje anterior. {0} is a number for seconds Anterior Registro en el foro - {0} {0} is name of forum Gracias por registrarse en {0}.{2}{2}{1} {0}=name of forum, {1}=forum link, {2}=new line Gracias por registrarse en {0}. Por favor siga este enlace para confirmar su registro:{5}{5}{1} {5}{5} Si no puede seguir el enlace, por favor visite la siguiente página, y copie y pegue el código de confirmación:{5}{5} Enlace: {2} {5}Código: {3} {5}{5}{4} {0}=name of forum, {1}=auth link, {2}=verify link, {3}=auth code, {4}=sig, {5}new line Configuracion grabada Hoy, {0} {0} is a time like 1:43pm Ese tópico no existe Ayer, {0} {0} a time like 1:43pm Cargando Votado <a href="{0}">{1}</a> votó por un post en el tópico: <a href="{2}">{3}</a> {0} is uri to user, {1} is user name, {2} is uri to post, {3} is topic name Puntuación del juego Premios Definiciones caso Definiciones premio Manual de eventos Nada Feed de actividad <a href="{0}">{1}</a> hizo un post en el tema: <a href="{2}">{3}</a> {0} is uri to user, {1} is user name, {2} is uri to post, {3} is topic name <a href="{0}">{1}</a> comenzó un nuevo tema: <a href="{2}">{3}</a> {0} is uri to user, {1} is user name, {2} is uri to topic{3} is topic name Conexiones externas Usar cuenta del foro existente Esa conexión ha caducado o no es válido Logins externos están desactivadas No tiene conexiones externas registrados Eliminar permanentemente Vista preliminar Stellen Sie eine Frage Respuestas Elija respuesta Publicar respuesta <a href="{0}">{1}</a> optó por una respuesta para la pregunta: <a href="{2}">{3}</a> {0} is uri to user, {1} is user name, {2} is uri to post, {3} is topic name Enviar respuesta Aplicación requiere reiniciar Hubo un error de búsqueda Cerrar temas antiguos dias Foro privado Cuenta no verificada Elegir texto Notificaciones <b>{0}</b> hizo un post en el tema: <b>{1}</b> Premio <b>{0}</b> votó por un post en el tópico: <b>{1}</b> <b>{0}</b> optó por una respuesta para la pregunta: <b>{1}</b> Sigue automáticamente los temas a los que respondes Marcar todo como leido Cargar imagen Ignorada Lista de ignorados Ignorar ================================================ FILE: src/PopForums/Resources/Resources.fr.resx ================================================  text/microsoft-resx 2.0 System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 Compte Compte créé Votre compte a été créé et vous pouvez commencer à utiliser le forum. Veuillez vérifier vos e-mails pour un lien de vérification afin de commencer à utiliser le forum. Votre compte a été vérifié Vous êtes déjà connecté et avez un compte. (tel que vous souhaitez qu'il apparaisse) (au moins six caractères) par contexte : "Dernier message : 12h par Jeff" Créer un compte Créer un compte Modifier votre profil E-mail Il y a eu un problème lors de l'envoi de votre e-mail de vérification : Favoris Désolé, vous n'avez pas la permission de voir ceci. Forums J'ai lu et j'accepte les conditions d'utilisation Dernier Connexion Déconnexion Marquer tous les forums comme lus Mon anniversaire est le ou avant le {0} où {0} est une date Nom Besoin de vérifier un compte existant ? Page non trouvée Mot de passe Messages Messages privés Récent Utilisateurs inscrits Retapez le mot de passe Rechercher S'abonner à la liste de diffusion Abonnements Conditions d'utilisation Fuseau horaire Sujets Total Total des messages Total des sujets Utiliser l'heure d'été Amérique du Nord/Europe Utilisateurs en ligne Vous devez avoir 13 ans ou plus pour vous inscrire Vous devez accepter les conditions d'utilisation Retapez votre mot de passe Nom requis Ce nom est déjà utilisé E-mail requis Adresse e-mail valide requise Cette adresse e-mail est déjà utilisée Cette adresse e-mail a été bannie Votre adresse IP a été bannie Le mot de passe retapé doit correspondre Modifier le compte Modifier le profil Veuillez entrer votre code de vérification Page d'accueil du forum Vous devez être inscrit et connecté pour modifier votre compte. Aucun utilisateur trouvé avec cet e-mail Envoyer un e-mail avec un nouveau code Votre e-mail de vérification a été envoyé Échec de la vérification Si vous avez besoin d'un nouveau code de vérification, entrez votre e-mail ici Désolé, le lien de vérification que vous avez suivi ou le code que vous avez entré n'est pas valide. Vérifier le compte Vérifier le code Avatar Supprimer cet avatar Changer d'e-mail Changer l'e-mail et envoyer le code Changer votre adresse e-mail générera un nouveau code de vérification qui sera envoyé à la nouvelle adresse. Vous ne pourrez pas poster de messages tant que vous n'aurez pas vérifié votre nouvelle adresse e-mail avec le nouveau code. Changer le mot de passe Changer votre e-mail ou mot de passe Date de naissance Détails Votre e-mail a été changé avec succès Nouvelle adresse e-mail Retapez la nouvelle adresse e-mail E-mail non trouvé Votre e-mail a été envoyé Ces adresses e-mail ne correspondent pas Envoyer un e-mail à l'utilisateur Forcer les zones de texte brut Entrez l'adresse e-mail associée à votre compte pour recevoir les instructions de réinitialisation de votre mot de passe : Instructions envoyées à votre e-mail Mot de passe oublié Mot de passe oublié ? Masquer la vanité dans contexte : "commencé par Jeff dans Forum Général" Inscrit le Localisation Vous êtes déjà connecté. E-mail ou mot de passe incorrect Gérer les photos Gérer vos photos Nouveau mot de passe enregistré Pas inscrit ? Ancien mot de passe incorrect Options Nouveau mot de passe Retapez le nouveau mot de passe Ancien mot de passe Réinitialiser le mot de passe Ceci n'est pas un lien valide de réinitialisation de mot de passe. Votre nouveau mot de passe est actif et vous êtes maintenant connecté. Réinitialisation du mot de passe réussie Photo Supprimer cette photo (cette image n'a pas encore été approuvée par un administrateur) Profil mis à jour Se souvenir de moi ? Réponses Enregistrer Envoyer l'e-mail Envoyer un message privé Afficher les détails de votre profil (sauf l'e-mail) Signature Commencé par Sujet Texte À Téléverser nouveau Utilisateur non trouvé Vues Web Visiter le site web de l'utilisateur Votre adresse IP Anniversaire Contact Profil Envoyer un e-mail à {0} {0} est un nom Envoyer un message privé à {0} {0} est un nom Se désabonner Échec du désabonnement Le lien que vous avez suivi n'est pas valide. Votre demande de désabonnement a été traitée. Vous pouvez vous réabonner en vous connectant et en allant sur votre page de compte. Ajouter un forum Ajouter nouveau Autoriser les images Archivé Êtes-vous sûr ? Bannir Retirer le bannissement Catégories Catégorie Modifier le titre de la catégorie Une erreur inconnue est survenue lors de la tentative de déplacement de la catégorie Caractère de remplacement de censure Mots à censurer Supprimer Supprimer et bannir l'utilisateur Supprimer l'utilisateur Description Descendre Modifier Modifier le forum Modifier l'utilisateur Bannissement d'e-mail Bannissement d'e-mail/IP Nouvel e-mail (optionnel) Envoyer un e-mail aux utilisateurs Journal des erreurs Forum Adaptateur de forum (optionnel, utiliser "Namespace.Type, AssemblyName") Accueil du forum Permissions du forum Paramètres du forum Forums non catégorisés Paramètres généraux Bannissement d'IP Historique des IP Est approuvé Est abonné Journaliser les erreurs Journalisation Journaliser la modération Journaliser les actions de sécurité Temps minimum entre les messages (secondes) Journal de modération Déplacer Le nouvel utilisateur est approuvé sans vérification L'image du nouvel utilisateur est approuvée sans modération Analyse Nouveau mot de passe (optionnel) Administration de POP Forums Messages par page Rôle Rôles Sécurité Journal de sécurité Heure du serveur Services Durée de session (minutes) Taille Le sujet et le corps ne doivent pas être vides Titre Sujets par page Monter Dimensions maximales de l'avatar utilisateur ATTENTION ! Supprimer un compte est permanent Approbation des images utilisateur Dimensions maximales de l'image utilisateur Rôles utilisateur Visible Ajouter un nouveau forum Corps Corps HTML Cliquez pour charger des messages plus récents avant de répondre Commentaire Commentaires (optionnels) Créer un nouveau mot indésirable Créez votre réponse Créer un nouveau rôle Créez votre nouveau sujet Supprimer toutes les erreurs Supprimer le mot indésirable sélectionné Supprimer le rôle sélectionné Modifier le message E-mail mis en file d'attente pour les utilisateurs abonnés. Date de fin Événement Heure de l'événement Vous devez être connecté pour voir les sujets favoris. Vous n'avez aucun sujet favori. Sujets favoris Une erreur inconnue est survenue lors de la tentative de déplacement du forum Pour supprimer les restrictions de publication ou de visualisation, supprimez tous les rôles de la case appropriée. Adresse e-mail de l'expéditeur ID Inclure la signature Intervalle d'indexation IP Est en cours d'exécution Mots indésirables Marquer le forum comme lu Message Millisecondes Messages de {0} {0} est un nom (optionnel) Admin et Modérateur sont des rôles permanents et ne peuvent pas être supprimés. ID du message Rôles de publication Publier un nouveau sujet Aperçu du sujet prévisualiser le verbe, pas l'adjectif Retirer Tout retirer Intervalle d'envoi Mot de passe SMTP Port (par défaut 25) Serveur SMTP Utilisateur SMTP Date de début Soumettre un nouveau sujet Soumettre une réponse ID du sujet Type Utiliser ESMTP pour les identifiants ID utilisateur ID utilisateur cible Les images téléversées par les utilisateurs ne nécessitent actuellement pas d'approbation Utiliser SSL Rôles de visualisation Fermer Fermer à la réponse Ajouter aux favoris Retirer des favoris Lien Modérateur Avatar de {0} {0} est un nom Dernière modification par {0} {0} est un nom Ouvrir Épingler Publier une réponse Citer Sujets récents Répondre Afficher plus de messages Afficher les messages précédents S'abonner Restaurer Désépingler Mettre à jour Archiver Nouveau message privé Aucun résultat trouvé Envoyer Désarchiver Voir les messages archivés Nom d'affichage Erreur Une erreur est survenue lors de la configuration de votre base de données Votre forum est prêt. Titre du forum Allez dans la section d'administration pour terminer la configuration de votre forum. Ce lien n'est plus valide. Aucune connexion de données Configuration de POP Forums L'installation ne peut pas se connecter au magasin de données. Veuillez vérifier votre chaîne de connexion et votre configuration. Une connexion à la base de données a été établie avec succès. Veuillez remplir les valeurs suivantes pour commencer Configurer la base de données Configurez votre premier utilisateur (se verra accorder les droits d'administrateur et de modérateur) Sujets abonnés Vous devez être connecté pour voir les sujets auxquels vous êtes abonné. Vous n'êtes abonné à aucun sujet. Succès ! {0} est désabonné de {1} {0} est un nom, {1} est un titre de sujet Archivé avec de nouveaux messages Sujet fermé Sujet fermé, épinglé Supprimer le sujet Premier Pour réinitialiser votre mot de passe sur {0}, veuillez suivre ce lien :{3}{1}{3}{3}{2} {0}=titre du forum, {1}=lien e-mail, {2}=signature, {3}=nouvelle ligne Vos instructions de réinitialisation de mot de passe pour {0} {0} est le nom du forum Vous n'êtes pas autorisé à publier dans ce forum Vous n'êtes pas autorisé à voir ce forum Il y a moins d'une minute Vous devez être connecté pour publier Il y a {0} minutes {0} est un nombre Plus Nouveaux messages Nouveaux messages, sujet fermé Nouveaux messages, sujet fermé & épinglé Nouveaux messages, sujet épinglé Suivant Aucun nouveau message Vous n'êtes pas connecté Il y a 1 minute Sujet épinglé Doit inclure un message et au moins un utilisateur Impossible de créer un message vide Vous devez attendre {0} secondes entre les messages et ne pouvez pas dupliquer votre dernier message {0} est un nombre de secondes Précédent Inscription au forum - {0} {0} est le nom du forum Merci de vous être inscrit sur {0}.{2}{2}{1} {0}=nom du forum, {1}=lien du forum, {2}=nouvelle ligne Merci de vous être inscrit sur {0}. Veuillez suivre ce lien pour confirmer votre inscription :{5}{5}{1} {5}{5}Si vous ne pouvez pas suivre ce lien, veuillez visiter la page suivante et copier-coller le code de confirmation :{5}{5}Lien : {2} {5}Code : {3} {5}{5}{4} {0}=nom du forum, {1}=lien d'authentification, {2}=lien de vérification, {3}=code d'authentification, {4}=signature, {5}=nouvelle ligne Paramètres enregistrés Aujourd'hui, {0} {0} est une heure comme 13h43 Ce sujet n'existe pas Hier, {0} {0} une heure comme 13h43 Chargement Voté <a href="{0}">{1}</a> a voté pour un message dans le sujet : <a href="{2}">{3}</a> {0} est l'URI de l'utilisateur, {1} est le nom d'utilisateur, {2} est l'URI du message, {3} est le nom du sujet Jeu de score Récompenses Définitions d'événements Définitions de récompenses Événement manuel Aucun Flux d'activité <a href="{0}">{1}</a> a fait un message dans le sujet : <a href="{2}">{3}</a> {0} est l'URI de l'utilisateur, {1} est le nom d'utilisateur, {2} est l'URI du message, {3} est le nom du sujet <a href="{0}">{1}</a> a commencé un nouveau sujet : <a href="{2}">{3}</a> {0} est l'URI de l'utilisateur, {1} est le nom d'utilisateur, {2} est l'URI du sujet, {3} est le nom du sujet Connexions externes Utiliser un compte forum existant Cette connexion a expiré ou n'est pas valide Les connexions externes sont désactivées Vous n'avez aucune connexion externe enregistrée Supprimer définitivement Aperçu Poser une question Réponses <a href="{0}">{1}</a> a choisi une réponse pour la question : <a href="{2}">{3}</a> {0} est l'URI de l'utilisateur, {1} est le nom d'utilisateur, {2} est l'URI du message, {3} est le nom du sujet Choisir une réponse Poster une réponse Soumettre une réponse Un redémarrage de l'application est requis Une erreur de recherche est survenue Vous pourriez être un robot Fermer les anciens sujets jours Forum privé Le compte n'est pas vérifié Sélectionner le texte Notifications <b>{0}</b> a fait un message dans le sujet : <b>{1}</b> Récompense <b>{0}</b> a voté pour un message dans le sujet : <b>{1}</b> <b>{0}</b> a choisi une réponse pour la question : <b>{1}</b> Suivre automatiquement les sujets auxquels vous répondez Tout marquer comme lu Téléverser une image Ignoré Ignorer Liste d'ignorés ================================================ FILE: src/PopForums/Resources/Resources.nl.resx ================================================  text/microsoft-resx 2.0 System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 Account Account Aangemaakt Je account is aangemaakt en je kunt nu gebruik maken van het forum. Controleer a.u.b. je e-mail voor de verificatielink om gebruik te kunnen maken van het forum. Je account is geverifieerd Je bent reeds ingelogd en hebt een account. (zoals je het eruit wilt laten zien) (op z'n minst zes karakters) by context: "Laatste post: 12am door Jeff" Maak Account Maak een account Bewerk je profiel E-mail Er was een probleem bij het verzenden van je verificatiemail: Favorieten Sorry, je hebt niet de juiste bevoegdheden om dit te bekijken. Forums Ik heb de algemene voorwaarden gelezen en ga hiermee akkoord Laatste Login Logout Markeer alle forums als gelezen Mijn geboortedatum is voor {0} where {0} is a date Naam Nodig om een bestaand account te verifieren? Pagina niet gevonden Wachtwoord Posts Privé Berichten Recent Geregistreerde gebruikers Type wachtwoord opnieuw Zoek Aanmelden voor de mailing list Aanmeldingen Algemene Voorwaarden Tijdzone Topics Totaal Totaal aantal posts Totaal aantal topics Use N.America/Europe Daylight Saving Gebruikers Online Je moet 13 of ouder zijn om je te kunnen registreren Je moet de algemene voorwaarden accepteren Voer je wachtwoord opnieuw in Naam verplicht Die naam is reeds in gebruik E-mail is verplicht Geldig e-mail adres is verplicht Dat e-mail adres wordt al gebruikt Dat e-mail adres is geblokkeerd Je IP adres is geblokkeerd Het opnieuw ingevoerde wachtwoord moet overeenkomen Bewerk Account Bewerk Profiel Voer a.u.b. je verificatiecode in Forum home page Je moet geregistreerd en ingelogd zijn om je account te kunnen editen. Er is geen gebruiker gevonden met dat e-mail adres Stuur E-mail Met Nieuwe Code Je verificatiemail is verzonden Verificatie Mislukt Als je een nieuwe verificatiecode nodig hebt, voer dan hier je e-mailadres in Sorry, de verificatielink waarop je hebt geklikt of de code die je ingevoerd hebt is ongeldig. Controleer Account Controleer Code Avatar Verwijder deze avatar Verander E-mailadres Verander e-mailadres en verzend code Bij het veranderen van je e-mailadres, zal er een nieuwe verificatiecode gegenereerd en verstuurd worden naar het nieuwe e-mailadres. Je zult niet meer kunnen posten totdat je e-mailadres is geverifieerd met de nieuwe code. Verander Wachtwoord Verander je e-mailadres of wachtwoord Geboortedatum Details Je e-mailadres is succesvol aangepast Nieuw e-mailadres Voer het nieuwe e-mailadres nogmaals in E-mailadres niet gevonden Je e-mail is verzonden De e-mailadressen komen niet overeen Mail Gebruiker Forceer uitsluitend-tekst tekstvelden Voer het e-mailadres in dat geassocieerd is met je account om de instructies voor het resetten van je wachtwoord te ontvangen: Instructies zijn verzonden naar je e-mailadres Wachtwoord Vergeten Je wachtwoord vergeten? Verberg ijdelheid in context: gestart door Jeff in General Forum Samengekomen Lokatie Je was al ingelogd. Verkeerd e-mailadres of wachtwoord Beheer Foto's Beheer je foto's Nieuw wachtwoord opgeslagen Niet geregisteerd? Het oude wachtwoord is incorrect Opties Nieuw wachtwoord Nieuw wachtwoord nogmaals Oud wachtwoord Reset Wachtwoord Dit is geen geldige wachtwoord reset link. Je nieuwe wachtwoord is actief en je bent nu ingelogd. Wachtwoord Reset Succesvol Foto Verwijder deze foto (dit plaatje is nog niet goedgekeurd door een beheerder) Profiel bijgewerkt Onthouden? Antwoorden Opslaan Verstuur Mail Verstuur privébericht Toon je profieldetails (met geen e-mail) Handtekening Gestart door Onderwerk Tekst Aan Upload nieuw Gebruiker niet gevonden Weergaven Web Bezoek gebruiker web site Jou IP adres Geboortedatum Contact Profiel Verstuur {0} een e-mail bericht {0} is a name Verstuur {0} een privébericht {0} is a name Afmelden Fout bij Afmelden De link waarop je klikte is ongeldig. Je afmeldingsaanvraag is verwerkt. Je kunt je weer aanmelden door in te loggen en naar je accountpagine te gaan. Forum Toevoegen Nieuwe Toevoegen Afbeeldingen toestaan Gearchiveerd Weet je het zeker? Blokkeren Verwijder Blokkade Categorieën Categorie Bewerk Categorietitel Er is een onbekende fout opgetreden tijdens het verplaatsen van de categorie Censuurvervangingskarakter Censuurwoorden Verwijderen Verwijder en Blokeer Gebruiker Verwijder Gebruiker Omschrijving Beneden Bewerk Bewerk Forum Bewerk Gebruiker Blokkeer E-mail Blokkeer E-mail/IP Nieuwe e-mail (optioneel) Mail Gebruikers FoutLog Forum Forum Adapter (optional, use "Namespace.Type, AssemblyName") Forum Home Forum Permissies Forum Instellingen Ongecatagorizeerde Forums Algemene Instellingen IP Blokkage IP Historie Is goedgekeurd Is aangemeld Log fouten Logging Log moderatie Log beveiligingsacties Minimale tijd tussen posts (seconden) Moderatie Log Verplaats Nieuwe gebruiker is goedgekeurd zonder verificatie Nieuwe gebruikerafbeelding is goedgekeurd zonder moderatie Parsing Nieuw wachtwoord (optioneel) POP Forums Beheer Posts per pagina Rol Rollen Beveiliging Beveiligingslog Server Tijd Services Sessielengte (minuten) Grootte Onderwerp en inhoud mogen niet leeg zijn Titel Topics per pagina Omhoog Gebruiker avatar max dimensies WAARSCHUWING! Verwijderen van een account is permanent Gebruikerafbeelding Goedkeuring Gebruikerafbeelding max dimensies Gebruiker Rollen Zichtbaar Voeg een nieuw forum toe Inhoud HTML Inhoud Klik om nieuwere posts op te halen voor dat je antwoordt Comment Comments (optineel) Maak Nieuw Junk Woord Maak je antwoord Maak Nieuwe Rol Maak je nieuwe topic Verwijder alle fouten Verwijder Geselecteerde Junk Woorden Verwijder Geselecteerde Rol Bewerk Post E-mail in de wachtrij geplaatst voor de aangemelde gebruikers. Einddatum Evenement Evenementstijd Je moet ingelogd zijn om favoriete topics te kunnen bekijken. Je hebt geen favoriete topics. Favoriete Topics Er is een onbekende fout opgetreden tijdens het verplaatsen van het forum Om post- en weergaverestricties te verwijderen, verwijder alle rollen van de toepasselijke box. Van e-mailadres ID Gebruik handtekening Indexeringsinterval IP Loopt Junk Woorden Markeer forum als gelezen Bericht Milliseconden {0}s posts {0} is a name (optioneel) Beheerder en Moderator zijn permanente rollen en kunnen niet verwijderd worden. PostID Posting Rollen Plaats een nieuw topic Toon voorbeeld van topic preview the verb, not adjective Verwijder Verwijder Alles Verzendingsinterval SMTP Wachtwoord Poort (standaard is 25) SMTP Server SMTP Gebruiker Startdatum Plaats new topic Plaats antwoord TopicID Type Use ESMTP for credentials UserID Doel UserID Door de gebruiker geuploade afbeeldingen hebben momenteel geen goedkeuring nodig Gebruik SSL Leesrollen Sluiten Sluiten bij antwoord Maak Favoriet Verwijder Van Favorieten Link Moderator {0}s avatar {0} is a name Laatst bijgewerkt door {0} {0} is a name Open Pin Post een antwoord Citaat Recente Topics Antwoord Toon meer posts Toon vorige posts Aanmelden Verwijderen ongedaan maken Unpin Update Archief Nieuw Privébericht Geen resultaten gevonden Verstuur Onarchiveer Toon gearchiveerde berichten Weergavenaam Fout Er is een fout opgetreden bij het instellen van je database Je forum is klaar. Forumtitel Ga naar de beheersecetie om het instellen van je forum af te ronden Die link is niet meer geldig. Geen dataconnectie POP Forums Setup De setup kan de data store niet benaderen. Controleer a.u.b. de connection string en de configuratie. A database connection has been successful. Please fill out the following values to get started Stel de database in Maak je eerste gebruiker aan (die zal beheer- en moderatorrechten krijgen) Aangemelde Topics Je moet ingelogd zijn om aangemelde topics te kunnen zien. Je bent niet aangemeld voor een topic. Succesvol! {0} is afgemeld van {1} {0} is a name, {1} is a topic title Gearchiveerd met nieuwe posts Topic gesloten Gesloten, gepind topic Verwijder Topic Eerste Om je wachtwoord te resetten op {0}, volg a.u.b. deze link:{3}{1}{3}{3}{2} {0}=title of forum, {1}=email link, {2}=signature, {3}=new line Je wachtwoordresetinstructies voor {0} {0} is name of forum Je hebt geen toestemming om in dit forum te posten Je hebt geen toestemming om dit forum te bekijken Minder dan een minuut geleden Je dient ingelogd te zijn om te kunnen posten {0} minuten geleden {0} is a number Meer Nieuwe posts Nieuwe posts, gesloten topic Nieuwe posts, gesloten & gepinde topic Nieuwe posts, gepinde topic Volgende Geen nieuwe posts Je bent niet ingelogd 1 minuut geleden Gepinde topic Je moet een bericht en op z'n minst één gebruiker toevoegen Je kunt geen lege post maken Je dient {0} seconden te wachten tussen je posts en je mag je laatste post niet herposten {0} is a number for seconds Vorige Forum registratie - {0} {0} is name of forum Dank je voor je registratie bij {0}.{2}{2}{1} {0}=name of forum, {1}=forum link, {2}=new line Dank je voor je registratie bij {0}. Klikt op de deze link om je registratie te bevestigen :{5}{5}{1} {5}{5}Als de link niet werkt, bezoek dan de volgende pagina, en voer de bevestigingscode in:{5}{5}Link: {2} {5}Code: {3} {5}{5}{4} {0}=name of forum, {1}=auth link, {2}=verify link, {3}=auth code, {4}=sig, {5}new line Instellingen opgeslagen Vandaag, {0} {0} is a time like 1:43pm Die topic bestaat niet Gisteren, {0} {0} a time like 1:43pm Laden Gestemd <a href="{0}">{1}</a> gestemd op een post in het topic: <a href="{2}">{3}</a> {0} is uri to user, {1} is user name, {2} is uri to post, {3} is topic name Scoren spel Geen Activiteitentoevoer <a href="{0}">{1}</a> maakte een bericht in het onderwerp: <a href="{2}">{3}</a> {0} is uri to user, {1} is user name, {2} is uri to post, {3} is topic name <a href="{0}">{1}</a> begon een nieuw onderwerp: <a href="{2}">{3}</a> {0} is uri to user, {1} is user name, {2} is uri to topic{3} is topic name Externe Logins Gebruik bestaande forum gehouden Dat login is verlopen of niet geldig is Externe logins zijn uitgeschakeld Je hebt geen externe logins geregistreerd Permanent verwijderen Voorbeeld Antwoorden Kies antwoord bericht antwoord <a href="{0}">{1}</a> koos voor een antwoord op de vraag: <a href="{2}">{3}</a> {0} is uri to user, {1} is user name, {2} is uri to post, {3} is topic name submit antwoord App herstart vereist Er is een zoekfout opgetreden Sluit oude onderwerpen dagen Privé forum Kontoen er ikke bekreftet Selecteer tekst Meldingen <b>{0}</b> maakte een bericht in het onderwerp: <b>{1}</b> <b>{0}</b> gestemd op een post in het topic: <b>{1}</b> <b>{0}</b> koos voor een antwoord op de vraag: <b>{1}</b> Volg automatisch onderwerpen waarop u reageert Markeer alles als gelezen Afbeelding uploaden Genegeerd Lijst negeren Negeren ================================================ FILE: src/PopForums/Resources/Resources.resx ================================================  text/microsoft-resx 2.0 System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 Account Account Created Your account was created and you may begin using the forum. Please check your e-mail for a verification link to begin using the forum. Your account has been verified You are already logged in and have an account. (as you would like it to appear) (at least six characters) by context: "Last post: 12am by Jeff" Create Account Create an account Edit your profile E-mail There was a problem sending your verification e-mail: Favorites Sorry, you don't have permission to view this. Forums I have read and accept the terms of service Last Login Logout Mark all forums read My birthday is on or before {0} where {0} is a date Name Need to verify an existing account? Page not found Password Posts Private Messages Recent Registered users Retype password Search Subscribe to mailing list Subscriptions Terms of service Time zone Topics Total Total posts Total topics Use N.America/Europe Daylight Saving Users Online You must be 13 or older to register You must accept the terms of service Retype your password Name required That name is already in use E-mail required Valid e-mail address required That e-mail address is already in use That e-mail address has been banned Your IP address has been banned Retyped password must match Edit Account Edit Profile Please enter your verification code Forum home page You must be registered and logged in to edit your account. No user found with that e-mail Send E-mail With New Code Your verification e-mail has been sent Verification Failure If you need a new verification code, enter your e-mail here Sorry, the verification link you followed or code you entered is not valid. Verify Account Verify Code Avatar Delete this avatar Change E-mail Change e-mail and send code Changing your e-mail address will cause a new verification code to be generated and sent to the new e-mail address. You will not be able to post until you verify your e-mail address with the new code. Change Password Change your e-mail or password Date of birth Details Your e-mail was changed successfully New e-mail address Retype new e-mail address E-mail not found Your e-mail has been sent Those e-mail addresses don't match E-mail User Force plain text boxes Enter the e-mail address associated with your account to receive instructions for resetting your password: Instructions sent to your e-mail Forgot Password Forgot your password? Hide vanity in context: started by Jeff in General Forum Joined Location You are already logged in. Bad e-mail or password Manage Photos Manage your photos New password saved Not registered? Old password incorrect Options New password Retype new password Old password Reset Password This is not a valid password reset link. Your new password is active and you're now logged in. Password Reset Successful Photo Delete this photo (this image has not yet been approved by an administrator) Profile updated Remember me? Replies Save Send E-mail Send Private Message Show your profile details (but not e-mail) Signature Started by Subject Text To Upload new User not found Views Web Visit user Web site Your IP address Birthday Contact Profile Send {0} an e-mail message {0} is a name Send {0} a private message {0} is a name Unsubscribe Unsubscribe Failure The link you followed is not valid. Your unsubscribe request has been processed. You can re-subscribe by logging in and going to your account page. Add Forum Add New Allow images Archived Are you sure? Ban Remove Ban Categories Category Edit Category Title There was an unknown error while attempting to move category Censor replacement character Censor words Delete Delete and Ban User Delete User Description Down Edit Edit Forum Edit User E-mail Ban E-mail/IP Ban New e-mail (optional) E-mail Users ErrorLog Forum Forum Adapter (optional, use "Namespace.Type, AssemblyName") Forum Home Forum Permissions Forum Settings Uncategorized Forums General Settings IP Ban IP History Is approved Is subscribed Log errors Logging Log moderation Log security actions Minimum time between posts (seconds) Moderation Log Move New user is approved without verification New user image approved without moderation Parsing New password (optional) POP Forums Administration Posts per page Role Roles Security Security Log Server Time Services Session length (minutes) Size Subject and body must not be empty Title Topics per page Up User avatar max dimensions WARNNG! Deleting an account is permanent User Image Approval User image max dimensions User Roles Visible Add a new forum Body HTML Body Click to load newer posts before you reply Comment Comments (optional) Create New Junk Word Create your reply Create New Role Create your new topic Delete all errors Delete Selected Junk Word Delete Selected Role Edit Post E-mail queued to subscribed users. End date Event Event Time You must be logged in to view favorite topics. You don't have any favorite topics. Favorite Topics There was an unknown error while attempting to move forum To remove posting or viewing restrictions, remove all roles from the appropriate box. From e-mail address ID Include signature Indexing Interval IP Is Running Junk Words Mark forum read Message Milliseconds {0}'s posts {0} is a name (optional) Admin and Moderator are permanent roles, and can't be deleted. PostID Posting Roles Post a new topic Preview topic preview the verb, not adjective Remove Remove All Sending interval SMTP Password Port (default is 25) SMTP Server SMTP User Start date Submit new topic Submit reply TopicID Type Use ESMTP for credentials UserID Target UserID User uploaded images currently do not require approval Use SSL Viewing Roles Close Close on reply Make Favorite Remove From Favorites Link Moderator {0}'s avatar {0} is a name Last edited by {0} {0} is a name Open Pin Post a reply Quote Recent Topics Reply Show more posts Show previous posts Subscribe Undelete Unpin Update Archive New Private Message No results found Send Unarchive View archived messages Display name Error There was an error setting up your database Your forum is ready. Forum title Go to the admin section to finish setting up your Forum. That link is no longer valid. No Data Connection POP Forums Setup The setup can't connect to the data store. Please check your connection string and configuration. A database connection has been successful. Please fill out the following values to get started Setup the database Setup your first user (will be granted admin and moderator rights) Subscribed Topics You must be logged in to view subscribed topics. You aren't subscribed to any topics. Success! {0} is unsubscribed from {1} {0} is a name, {1} is a topic title Archived with new posts Closed topic Closed, pinned topic Delete Topic First To reset your password on {0}, please follow this link:{3}{1}{3}{3}{2} {0}=title of forum, {1}=email link, {2}=signature, {3}=new line Your password reset instructions for {0} {0} is name of forum You aren't allowed to post in this forum You aren't allowed to view this forum Less than a minute ago You must be logged in to post {0} minutes ago {0} is a number More New posts New posts, closed topic New posts, closed & pinned topic New posts, pinned topic Next No new posts You are not logged in 1 minute ago Pinned topic Must include message and at least one user Can't make an empty post You must wait {0} seconds between posts and may not duplicate your last post {0} is a number for seconds Previous Forum registration - {0} {0} is name of forum Thank you for registering with {0}.{2}{2}{1} {0}=name of forum, {1}=forum link, {2}=new line Thank you for registering with {0}. Please follow this link to confirm your registration:{5}{5}{1} {5}{5}If you can't follow this link, please visit the following page, and cut and paste the confirmation code:{5}{5}Link: {2} {5}Code: {3} {5}{5}{4} {0}=name of forum, {1}=auth link, {2}=verify link, {3}=auth code, {4}=sig, {5}new line Settings saved Today, {0} {0} is a time like 1:43pm That topic doesn't exist Yesterday, {0} {0} a time like 1:43pm Loading Voted <a href="{0}">{1}</a> voted for a post in the topic: <a href="{2}">{3}</a> {0} is uri to user, {1} is user name, {2} is uri to post, {3} is topic name Scoring game Awards Event Definitions Award Definitions Manual Event None Activity Feed <a href="{0}">{1}</a> made a post in the topic: <a href="{2}">{3}</a> {0} is uri to user, {1} is user name, {2} is uri to post, {3} is topic name <a href="{0}">{1}</a> started a new topic: <a href="{2}">{3}</a> {0} is uri to user, {1} is user name, {2} is uri to topic{3} is topic name External Logins Use existing forum account That login has expired or is not valid External logins are disabled You have no external logins registered Delete Permanently Preview Ask A Question Answers <a href="{0}">{1}</a> chose an answer for the question: <a href="{2}">{3}</a> {0} is uri to user, {1} is user name, {2} is uri to post, {3} is topic name Choose answer Post answer Submit answer App restart is required There was a search error You might be a robot Close old topics days Private forum Account is not verified Select Text Notifications <b>{0}</b> made a post in the topic: <b>{1}</b> Award <b>{0}</b> voted for a post in the topic: <b>{1}</b> <b>{0}</b> chose an answer for the question: <b>{1}</b> Automatically follow topics that you reply to Mark all read Upload Image Ignored Ignore Ignore List ================================================ FILE: src/PopForums/Resources/Resources.uk.resx ================================================  text/microsoft-resx 2.0 System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 Акаунт Акаунт створений Ваш акаунт був створений, і ви можете почати роботу з форумом. Будь ласка перевірте вашу електронну скриньку на наявність підтверджуючого повідомлення для початку користування форумом. Ваш акаунт був підтверджений Ви вже є залоговані і маєте акаунт. (Як би ви хотіли, щоб він з'явився) (як мінімум 6 символів) context: "Last post: 12am by Jeff" Створити Акаунт Створити акаунт Редагувати ваш профіль E-mail Відбулась проблема з надсиланням листа з перевіркою електронної пошти: Улюблені На жаль, у вас немає дозволу для перегляду цього. Форуми Я прочитав і згоден з умовами надання послуг Останній Вхід Вихід Позначити всі форуми як прочитані Мій день народження {0} where {0} is a date Ім'я Потрібно підтвердити існуючий акаунт? Сторінку не знайдено Пароль Повідомлення Приватні повідомлення Останні Зареєстровані користувачі Повторіть пароль Пошук Підписатися на розсилку Підписки Загальні положення та умови Часова зона Теми Сума Загальна кількість повідомлень Загальна кількість тем Використовувати N.America/Europe Daylight Saving Користувачі онлайн Ви повинні бути 13-річним або старше, щоб зареєструватися Ви повинні прийняти умови надання послуг Введіть пароль ще раз Ім'я потрібно Дане ім'я вже використовується E-mail потрібний Дійсна адреса електронної пошти потрібна Дана електронна скринька вже використовується Даний e-mail був забанений Ваша IP адреса була забанена Введені паролі не співпадають Редагувати Акаунт Редагувати Профіль Будь ласка, введіть код підтвердження Домашня сторінка форуму Ви повинні бути зареєстровані та залоговані щоб редагувати ваш акаунт. Не знайдено користувача з таким e-mail Надіслати E-mail З Новим Кодом Підтвердження було надіслане на вашу скриньку Підтвердження провалено Якщо вам потрібен новий код підтвердження, введіть свою адресу електронної пошти тут Вибачте, підтверджуюче посилання, за яким ви пробуєте перейти не є коректне. Підтвердити Акаунт Підтвердити Код Аватарка Видалити цей аватар Змінити E-mail Змінити e-mail і надіслати код Зміна вашої електронної пошти викличе генерацію нового коду підтвердження, який буде надісланий на нову адресу. Ви не будете мати змогу залишати повідомлення, поки ви не підтвердите вашу електронну адресу з новим кодом. Змінити Пароль Змінити вашу електронну скриньку або пароль Дата Народження Деталі Ваш e-mail був успішно змінений Нова e-mail адреса Повторіть нову e-mail адресу E-mail не було знайдено Ваш e-mail був відправлений Дані e-mail адреси не співпадають E-mail Користувача Прості текстові поля Введіть адресу електронної пошти, пов'язану з вашим аккаунтом, щоб отримати інструкції по зміні пароля: Інструкції були надіслані на електронну скриньку Забули Пароль Забули пароль? Hide vanity в context: started by Jeff in General Forum Зареєструвався Місцезнаходження Ви вже залоговані. Погана скринька або пароль Керування фотографіями Керування вашими фотографіями Новий пароль збережено Не зареєстровані? Старий пароль невірний Опції Новий пароль Підтвердіть ваш новий пароль Старий пароль Скинути Пароль Дане посилання на скидання паролю не є коректне. Ваш новий пароль активний і ви тепер увійшли в систему Скидання Паролю Пройшло Успішно Фото Видалити це фото (дана картинка не була підтверджена адміністратором) Профіль збережено Запамятати мене? Відповіді Зберегти Надіслати E-mail Надіслати Приватне Повідомлення Показати деталі вашого профілю (але не e-mail) Підпис Автор Тема Текст До Завантажити нову Користувача не знайдено Переглядів Веб Відвідати сайт користувача Ваша IP адреса День Народження Контакт Профіль Надіслати {0} an e-mail message {0} is a name Надіслати {0} приватне повідомлення {0} is a name Відписатись Відписування від теми невдалось Дане посилання не є правильним. Ваш запит на відписування прийнято для обробки. Ви можете підписатись знову,залогувавшись і перейшовши на сторінку вашого акаунта. Додати Форум Додати Новий Дозволити зображення В архіві Ви впевнені? Забанити Розбанити Категорії Категорія Редагувати Заголовок Категорії Була невідома помилка при спробі перемістити категорію Символи заміни цензури Цензурні слова Видалити Видалити і Забанити Користувача Видалити Користувача Опис Вниз Редагувати Редагувати Форум Редагувати Користувача Забанити E-mail E-mail/IP Бан Новий e-mail (бажано) E-mail Користувачів ЛогПомилок Форум Forum Adapter (optional, use "Namespace.Type, AssemblyName") Домашня Форуму Дозволи форуму Налаштування Форуму Форуми без категорій Загальні Налаштування IP Бан IP Історія Є підтверджений Є підписаний Помилки логування Логування Лог модерації Лог дій безпеки Мінімальний час між відповідями (секунд) Лог Модерації Перемістити Новий користувач затверджений без підтвердження Нове зображення користувача затверджене без модерації Розбір Новий пароль (бажано) POP Forums Administration Повідомлень на сторінку Роль Ролі Безпека Лог Безпеки Час Серверу Послуги Довжина сесії (хвилини) Розмір Тема і тіло не повинні бути порожніми Заголовок Тем на сторінку Вгору Максимальний розмір картинки користувача УВАГА! Видалення акаунту є необоротнє Підтвердження Картинки Користувача Максимальний розмір картинки користувача Ролі Користувача Видимий Додати новий форум Тіло HTML Тіло Натисніть щоб завантажити нові повідомлення перед вашою відповіддю Коментар Коментарі (за бажанням) Створити New Junk Word Створити вашу відповідь Створити Нову Роль Створити Нову Тему Видалити всі помилки Видалити Вибраний Junk Word Видалити Вибрану Роль Редагувати Повідомлення E-mail queued to subscribed users. Кінцева дата Подія Час Події Ви повинні залогуватись щоб переглядати улюблені теми. У вас немає улюблених тем. Улюблені Теми Відбулась невідома помилка при спробі перемістити форум Для видалення публікації або обмежень перегляду, видаліть всі ролі з відповідної скриньки. З e-mail адреси ID Додати підпис Інтервал індексу IP Is Running Junk Words Позначити форум як прочитаний Повідомлення Мілісекунд Повідомлення {0} {0} is a name (бажано) Адміністратор і Модератор є постійними ролями, і не можуть бути видалені. ID повідомлення Posting Roles Створити нову тему Попередній перегляд теми preview the verb, not adjective Видалити Видалити Все Інтервал Відправки SMTP Пароль Порт (25 є за замовчуванням) SMTP Сервер SMTP Користувач Дата початку Додати нову тему Надіслати відповідь ID Теми Тип Використовувати ESMTP for credentials ID користувача Target UserID Завантажені картинки користувача зараз не потребують схвалення. Використовувати SSL Перегляд Ролей Закрити Закрити після відповіді Додати до Улюблених Видалити з Улюблених Посилання Модератор Аватарка {0} {0} is a name Редаговане {0} {0} is a name Відкрити Закріпити Дати відповідь Цитата Останні теми Відповідь Показати більше повідомлень Показати попередні повідомлення Підписатись Відновити Відкріпити Редагувати Архів Нове приватне повідомлення Результатів не знайдено Надіслати Перенести з архіву Переглянути Повідомлення в Архіві Показати Ім'я Помилка Сталась помилка налаштовування вашої бази даних Ваш форум готовий до використання. Заголовок форуму Перейдіть на адмін панель щоб завершити налаштування вашого Форуму. Дане посилання вже не є коректним. Немає Підключення До Даних POP Forums Setup Програма установки не може підключитися до сховища даних. Будь ласка, перевірте ваш рядок підключення та налаштування. З'єднання з базою даних було успішне. Будь ласка, заповніть наступні поля, щоб почати роботу Налаштування бази даних Налаштуйте вашого першого користувача (який буде наділаний привілегіями адміна і модератора) Підписані теми Ви повинні увійти в систему для перегляду підписаних тем. Ви не підписані на жодну тему. Успіх! {0} відписався від {1} {0} is a name, {1} is a topic title В архіві з новими повідомленнями Закрита тема Закрита, прикріплена тема Видалити Тему Перший Щоб скинути ваш пароль на {0}, будь ласка перейдіть за посиланням:{3}{1}{3}{3}{2} {0}=title of forum, {1}=email link, {2}=signature, {3}=new line Інструкції скидання вашого паролю для {0} {0} is name of forum Ви не можете писати в цьому форумі Ви не можете переглядати цей форум Менше хвилини назад Вам потрібно залогінитись аби відповісти {0} хвилин назад {0} is a number Більше Нові повідомлення Нові повідомлення, закрита тема Нові повідомлення, закрита і прикріплена тема Нові повідомлення, прикріплена тема Наступний Ніяких нових повідомлень Ви не залоговані 1 хвилину назад Закріпити тему Повинна включати повідомлення та хоча б одного користувача Ви не можете відправити пусте повідомлення Вам потрібно почекати {0} секунд між створенням повідомлень і не можете повторювати ваше попереднє повідомлення {0} is a number for seconds Попередній Реєстрація на форумі - {0} {0} is name of forum Дякуємо за реєстрацію на {0}.{2}{2}{1} {0}=name of forum, {1}=forum link, {2}=new line Дякуємо за реєстрацію на {0}. Будь ласка підтвердіть вашу реєстрацію за наступним посиланням:{5}{5}{1} {5}{5}Якщо ви не можете перейти за вказаним посиланням, будь ласка відвідайте наступну сторінку і скопіюйте підтверджуючий код:{5}{5}Посилання: {2} {5}Код: {3} {5}{5}{4} {0}=name of forum, {1}=auth link, {2}=verify link, {3}=auth code, {4}=sig, {5}new line Налаштування збережено Сьогодні, {0} {0} is a time like 1:43pm Така тема не існує Вчора, {0} {0} a time like 1:43pm Завантаження Проголосований <a href="{0}">{1}</a>проголосував за повідомлення в темі: <a href="{2}">{3}</a> {0} is uri to user, {1} is user name, {2} is uri to post, {3} is topic name Scoring game Нагорода Опис Події Опис Нагороди Керівництво подіями Жоден Стрічка Актовиності <a href="{0}">{1}</a> зробив повідомлення в темі: <a href="{2}">{3}</a> {0} is uri to user, {1} is user name, {2} is uri to post, {3} is topic name <a href="{0}">{1}</a> розпочав нову тему: <a href="{2}">{3}</a> {0} is uri to user, {1} is user name, {2} is uri to topic{3} is topic name зовнішні логінів Використання існуючої облікової форум Цей логін завершився, або не застосовується Зовнішні входи відключені У вас немає зовнішніх логінів зареєстрованих Видалити файл Попередній Задайте питання відповіді Виберіть відповідь повідомлення Відповідь <a href="{0}">{1}</a> обрали відповідь на питання: <a href="{2}">{3}</a> {0} is uri to user, {1} is user name, {2} is uri to post, {3} is topic name Додати відповідь потрібне перезавантаження App Сталася помилка пошуку Закрийте старі теми днів Приватний форум Обліковий запис не підтверджено Виберіть Текст Сповіщення <b>{0}</b> зробив повідомлення в темі: <b>{1}</b> Нагорода <b>{0}</b>проголосував за повідомлення в темі: <b>{1}</b> <b>{0}</b> обрали відповідь на питання: <b>{1}</b> Автоматично слідкуйте за темами, на які ви відповідаєте Позначити все як прочитане Завантажити зображення Ігнорується Список ігнорування Ігнорувати ================================================ FILE: src/PopForums/Resources/Resources.zh-TW.resx ================================================  text/microsoft-resx 2.0 System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 帳戶 建立帳戶 您的帳號已建立,您可以開始使用了。 請檢查您的電子郵件中的確認連結。 您的帳戶已確認完畢 您已經登入,並且擁有一個帳戶。 (你想它顯示為什麼) (至少6個字符) 作者 建立帳戶 建立新帳戶 編輯您的個人資料 電子郵件 發送您的驗證郵件時出現問題: 我的最愛 很抱歉,您沒有權限。 論壇 我已閱讀並接受服務條款 最後 註冊 登出 標記所有論壇為已讀 我的生日是在或之前{0} 名稱 需要驗證現有帳戶? 找不到網頁 密碼 主題 私人訊息 最近 註冊用戶 重新輸入密碼 搜尋 訂閱郵件列表 訂閱 服務條款 時區 主題 總數 文章總數 主題總數 使用夏令時 上線人數 您必須年滿13歲才能註冊 您必須接受服務條款 請重新輸入您的密碼 名稱為必填 該名稱已被使用 E-mail為必填 E-mail無效 該E-mail地址已被使用 該E-mail地址已被封鎖 您的IP地址已被封鎖 您輸入的密碼不一致 編輯帳戶 編輯個人資料 請輸入您的驗證碼 論壇首頁 您必須註冊並登入編輯您的帳戶。 找不到用戶與郵件 發送E-mail隨著新守則 您的驗證郵件已發送 驗證失敗 如果你需要一個新的驗證碼,請在此輸入您的電子郵件 對不起,您輸入的驗證鏈接或代碼無效。。 驗證帳戶 驗證碼 頭像 刪除此頭像 更改電子郵件 更改電子郵件和發送代碼 改變您的電子郵件地址將導致生成並發送至新的電子郵件地址一個新的驗證碼。你將無法發表,直到您用新的代碼驗證您的電子郵件地址。 更改密碼 更改您的電子郵件或密碼 出生日期 詳細資訊 您的電子郵件已成功更改 新的E-mail地址 重新輸入新的電子郵件地址 電子郵件未找到 您的電子郵件已發送 這些電子郵件地址不匹配 E-mail用戶 強制純文字框 輸入與您的帳戶關聯的電子郵件地址,接收重置您的密碼的指令: 說明已發送到您的電子郵件 忘記密碼 忘記密碼? 隐藏头像和签名 已加入 位置 您已經登入。 錯誤的電子郵件或密碼 管理照片 管理您的照片 新密碼已儲存 沒有註冊? 舊密碼不正確 選項 新密碼 重新輸入新密碼 舊密碼 重設密碼 這不是一個有效的密碼重置連結。 你的新密碼生效,現在你已經登錄。 密碼重置成功 照片 刪除照片 (這個影像還未被管理員核准) 資料更新 還記得我嗎? 回覆 儲存 發送E-mail 發送私人訊息 顯示您的個人資料(但不是電子郵件) 簽名 開始 主題 文字 上傳新項目 找不到用戶 瀏覽次數 Web 用戶訪問的Web站點 您的IP地址 生日 聯絡 個人資料 發送{0}的電子郵件 發送{0}私人訊息 退訂 退訂失敗 這個退訂連結是無效的。 您的退訂請求已經被處理。您可以透過登入並進入你的帳戶頁面重新訂閱。 加入論壇 新增項目 允許圖片 已封存 你確定嗎? 封鎖 移除封鎖 分類 類別 編輯類別名稱 嘗試移動類別發生錯誤 審查替換字元 審查文字 刪除 刪除和封鎖用戶 刪除用戶 描述 向下 編輯 編輯論壇 編輯用戶 電子郵件封鎖 E-mail/IP封鎖 新的電子郵件(可選) 電子郵箱用戶 錯誤紀錄 論壇 論壇適配器(可選,使用「 Namespace.Type ,的AssemblyName 」 ) 論壇首頁 論壇權限 論壇設定 未分類論壇 一般設定 封鎖的IP IP歷史記錄 已核准 已訂閱 記錄錯誤 日誌 管理日誌 登錄安全活動 發布文章之間的最小時間(秒) 管理日誌 移動 新用戶將被核准無需驗證 新用戶圖像核准無須管理 解析 新密碼(可選) POP 論壇管理 每頁主題 角色 角色 安全 安全日誌 伺服器時間 服務 Session長度(分鐘) 大小 主題和內文不能為空 標題 每頁主題 向上 頭像最大尺寸 警告!刪除帳戶是無法復原的 用戶圖像核可 用戶圖片最大尺寸 用戶角色 可見 新增一個新的論壇 內文 HTML內文 點擊載入更多主題 評論 評論(可選) 建立垃圾字 建立您的回覆 建立新角色 建立新主題 刪除所有的錯誤 刪除選定垃圾字 刪除選定的角色 編輯主題 寄給訂閱用戶的 Email 已加入佇列 結束日期 事件 活動時間 您必須先登入才能查看喜愛的主題。 你沒有任何喜歡的主題。 最喜歡的主題 而試圖移動論壇有未知錯誤 要刪除張貼或查看限制,從適當的框中刪除所有角色。 從E-mail地址 ID 包含簽名 索引間隔 知識產權 正在運行 垃圾詞 標記論壇為已讀 資訊 毫秒 {0}的主題 (可選) 管理員和主持人是您永久的角色,而不能被刪除。 資訊ID 張貼角色 發表新主題 預覽主題 清除 全部刪除 發送間隔 SMTP密碼 端口(默認為25 ) SMTP伺服器 SMTP用戶 開始日期 提交新話題 提交回覆 TopicID 類型 使用ESMTP憑證 帳號 目標帳號 用戶上傳的圖片目前不需要核准 使用SSL 查看角色 關閉 關閉回應 設為收藏 從收藏夾中刪除 連結 管理者 {0}的頭像 最後編輯: {0} 釘選 發表回覆 引用 最近的話題 回覆 顯示更多主題 顯示以前的主題 訂閱 取消刪除 取消固定 更新 封存 新的私人資訊 沒有找到結果 發送 取消封存 查看已歸檔的消息 顯示名稱 錯誤 設定數據庫有錯誤 您的論壇已準備就緒。 論壇標題 進入管理部分來完成設定您的論壇。 該連結不再有效。 沒有數據連接 POP Forums 設定 安裝程序無法連接到數據存儲區。請檢查您的連接字符串和配置。 數據庫連接是成功的。請填寫下面的值 設定數據庫 設定您的第一個用戶(將被授予管理員和主持人權) 訂閱主題 您必須先登入才能查看訂閱的主題。 您還沒有訂閱任何主題。 成功! {0}是{1}退訂 封存新主題 鎖定的主題 封閉的,固定的話題 刪除主題 第一 要在{0}重置您的密碼,請點擊此連結: {3} {3} {3} {2} {1} 您的密碼重置說明{0} 你不可以在這個論壇發佈 您無權查看該論壇 不到一分鐘 您必須登入後才能發表 {0}分鐘前 更多 新主題 新主題已關閉 新主題已關閉已釘選 新主題已釘選 下一個 沒有新主題 您還沒有登入 1分鐘前 已釘選 必須包含消息和至少一位用戶 主題不能為空白 你必須等待{0}秒才能再次發表新的主題 以前 論壇註冊 - {0} 感謝您與{0}註冊{2} {2} {1} 感謝您與{0}註冊。請點擊此連結以確認您的註冊: {5} {5} {1} {5} {5} 如果你不能按照這個連結,請訪問以下頁面,剪切和粘貼的確認代碼: {5} {5}友情連結: {2} {5} 代碼: {3} {5} {5} {4} 設定已儲存 今天, {0} 該主題不存在 昨天, {0} 載入中 投票 <a href="{0}">{1}</a> 投票給主題: <a href="{2}">{3}</a> 得分王 獎項 事件定義 獎項定義 手動事件 活動資訊 <a href="{0}">{1}</a>的所做的主題在後:<a href="{2}">{3}</a> <a href="{0}">{1}</a>的開始一個新的話題: <a href="{2}">{3}</a> 外部登入 利用現有的論壇帳號 該登入已過期或無效 外部登入被禁用 你有沒有註冊的外部登入 永久删除 預覽 問一個問題 答案 选择答案 邮政的答案 <a href="{0}">{1}</a> 选择的问题的答案: <a href="{2}">{3}</a> {0} is uri to user, {1} is user name, {2} is uri to post, {3} is topic name 提交答案 需要重新启动应用程序 有一个搜索错误 關閉舊主題 私人論壇 帐号未验证 选择文本 通知 <b>{0}</b>的所做的主題在後:<b>{1}</b> 獎項 <b>{0}</b> 投票給主題: <b>{1}</b> <b>{0}</b> 选择的问题的答案: <b>{1}</b> 自动关注您回复的主题 标记所有已读 上传图片 被忽略 忽略列表 忽略 ================================================ FILE: src/PopForums/ScoringGame/AwardCalculator.cs ================================================ namespace PopForums.ScoringGame; public interface IAwardCalculator { Task QueueCalculation(User user, EventDefinition eventDefinition); Task ProcessCalculation(string eventDefinitionID, int userID); } public class AwardCalculator : IAwardCalculator { public AwardCalculator(IAwardCalculationQueueRepository awardCalcRepository, IEventDefinitionService eventDefinitionService, IUserRepository userRepository, IErrorLog errorLog, IAwardDefinitionService awardDefinitionService, IUserAwardService userAwardService, IPointLedgerRepository pointLedgerRepository, ITenantService tenantService) { _awardCalcRepository = awardCalcRepository; _eventDefinitionService = eventDefinitionService; _userRepository = userRepository; _errorLog = errorLog; _awardDefinitionService = awardDefinitionService; _userAwardService = userAwardService; _pointLedgerRepository = pointLedgerRepository; _tenantService = tenantService; } private readonly IAwardCalculationQueueRepository _awardCalcRepository; private readonly IEventDefinitionService _eventDefinitionService; private readonly IUserRepository _userRepository; private readonly IErrorLog _errorLog; private readonly IAwardDefinitionService _awardDefinitionService; private readonly IUserAwardService _userAwardService; private readonly IPointLedgerRepository _pointLedgerRepository; private readonly ITenantService _tenantService; public async Task QueueCalculation(User user, EventDefinition eventDefinition) { var tenantID = _tenantService.GetTenant(); var payload = new AwardCalculationPayload {EventDefinitionID = eventDefinition.EventDefinitionID, UserID = user.UserID, TenantID = tenantID}; await _awardCalcRepository.Enqueue(payload); } public async Task ProcessCalculation(string eventDefinitionID, int userID) { var eventDefinition = await _eventDefinitionService.GetEventDefinition(eventDefinitionID); var user = await _userRepository.GetUser(userID); if (eventDefinition == null) { _errorLog.Log(new Exception($"Event calculation attempt on nonexistent event \"{eventDefinitionID}\""), ErrorSeverity.Warning); return; } if (user == null) { _errorLog.Log(new Exception($"Event calculation attempt on nonexistent user {userID}"), ErrorSeverity.Warning); return; } var associatedAwards = await _awardDefinitionService.GetByEventDefinitionID(eventDefinition.EventDefinitionID); foreach (var award in associatedAwards) { if (award.IsSingleTimeAward) { var isAwarded = await _userAwardService.IsAwarded(user, award); if (isAwarded) continue; } var conditions = await _awardDefinitionService.GetConditions(award.AwardDefinitionID); var conditionsMet = 0; foreach (var condition in conditions) { var eventCount = await _pointLedgerRepository.GetEntryCount(user.UserID, condition.EventDefinitionID); if (eventCount >= condition.EventCount) conditionsMet++; } if (conditions.Count != 0 && conditionsMet == conditions.Count) await _userAwardService.IssueAward(user, award); } } } ================================================ FILE: src/PopForums/ScoringGame/AwardCalculatorWorker.cs ================================================ namespace PopForums.ScoringGame; public interface IAwardCalculatorWorker { void Execute(); } public class AwardCalculatorWorker(IAwardCalculator calculator, IAwardCalculationQueueRepository awardCalculationQueueRepository, IErrorLog errorLog) : IAwardCalculatorWorker { public async void Execute() { try { var nextItem = await awardCalculationQueueRepository.Dequeue(); if (string.IsNullOrEmpty(nextItem.Key)) return; await calculator.ProcessCalculation(nextItem.Key, nextItem.Value); } catch (Exception exc) { errorLog.Log(exc, ErrorSeverity.Error); } } } ================================================ FILE: src/PopForums/ScoringGame/AwardCondition.cs ================================================ namespace PopForums.ScoringGame; public class AwardCondition { public string AwardDefinitionID { get; set; } public string EventDefinitionID { get; set; } public int EventCount { get; set; } } ================================================ FILE: src/PopForums/ScoringGame/AwardDefinition.cs ================================================ namespace PopForums.ScoringGame; public class AwardDefinition { public string AwardDefinitionID { get; set; } public string Title { get; set; } public string Description { get; set; } public bool IsSingleTimeAward { get; set; } } ================================================ FILE: src/PopForums/ScoringGame/AwardDefinitionService.cs ================================================ namespace PopForums.ScoringGame; public interface IAwardDefinitionService { Task Get(string awardDefinitionID); Task> GetByEventDefinitionID(string eventDefinitionID); Task Create(AwardDefinition awardDefinition); Task Delete(string awardDefinitionID); Task> GetConditions(string awardDefinitionID); Task SaveConditions(AwardDefinition awardDefinition, List conditions); Task> GetAll(); Task DeleteCondition(string awardDefinitionID, string eventDefinitionID); Task AddCondition(AwardCondition awardDefintion); } public class AwardDefinitionService : IAwardDefinitionService { public AwardDefinitionService(IAwardDefinitionRepository awardDefintionRepository, IAwardConditionRepository awardConditionRepository) { _awardDefinitionRepository = awardDefintionRepository; _awardConditionRepository = awardConditionRepository; } private readonly IAwardDefinitionRepository _awardDefinitionRepository; private readonly IAwardConditionRepository _awardConditionRepository; public async Task Get(string awardDefinitionID) { return await _awardDefinitionRepository.Get(awardDefinitionID); } public async Task> GetAll() { return await _awardDefinitionRepository.GetAll(); } public async Task> GetByEventDefinitionID(string eventDefinitionID) { return await _awardDefinitionRepository.GetByEventDefinitionID(eventDefinitionID); } public async Task Create(AwardDefinition awardDefinition) { await _awardDefinitionRepository.Create(awardDefinition.AwardDefinitionID, awardDefinition.Title, awardDefinition.Description, awardDefinition.IsSingleTimeAward); } public async Task Delete(string awardDefinitionID) { await _awardDefinitionRepository.Delete(awardDefinitionID); } public async Task> GetConditions(string awardDefinitionID) { return await _awardConditionRepository.GetConditions(awardDefinitionID); } public async Task SaveConditions(AwardDefinition awardDefinition, List conditions) { await _awardConditionRepository.DeleteConditions(awardDefinition.AwardDefinitionID); foreach (var condition in conditions) condition.AwardDefinitionID = awardDefinition.AwardDefinitionID; await _awardConditionRepository.SaveConditions(conditions); } public async Task DeleteCondition(string awardDefinitionID, string eventDefinitionID) { await _awardConditionRepository.DeleteCondition(awardDefinitionID, eventDefinitionID); } public async Task AddCondition(AwardCondition awardDefintion) { await _awardConditionRepository.SaveConditions(new List {awardDefintion}); } } ================================================ FILE: src/PopForums/ScoringGame/EventDefinition.cs ================================================ namespace PopForums.ScoringGame; public class EventDefinition { public string EventDefinitionID { get; set; } public string Description { get; set; } public int PointValue { get; set; } public bool IsPublishedToFeed { get; set; } } ================================================ FILE: src/PopForums/ScoringGame/EventDefinitionService.cs ================================================ namespace PopForums.ScoringGame; public interface IEventDefinitionService { Task GetEventDefinition(string eventDefinitionID); Task> GetAll(); Task Create(EventDefinition eventDefinition); Task Delete(string eventDefinitionID); } public class EventDefinitionService : IEventDefinitionService { public static Dictionary StaticEvents = new Dictionary { {StaticEventIDs.PostVote, new EventDefinition {EventDefinitionID = StaticEventIDs.PostVote, Description = "Post vote", PointValue = 1, IsPublishedToFeed = true}}, {StaticEventIDs.PostVoteUndo, new EventDefinition {EventDefinitionID = StaticEventIDs.PostVoteUndo, Description = "Post vote undo", PointValue = -1, IsPublishedToFeed = true}}, {StaticEventIDs.NewPost, new EventDefinition {EventDefinitionID = StaticEventIDs.NewPost, Description = "New post", PointValue = 0, IsPublishedToFeed = true}}, {StaticEventIDs.NewTopic, new EventDefinition {EventDefinitionID = StaticEventIDs.NewTopic, Description = "New topic", PointValue = 0, IsPublishedToFeed = true}}, {StaticEventIDs.QuestionAnswered, new EventDefinition {EventDefinitionID = StaticEventIDs.QuestionAnswered, Description = "Question answered", PointValue = 10, IsPublishedToFeed = true}} }; public static class StaticEventIDs { public static string PostVote = "PostVote"; public static string PostVoteUndo = "PostVoteUndo"; public static string NewPost = "NewPost"; public static string NewTopic = "NewTopic"; public static string QuestionAnswered = "QuestionAnswered"; } public EventDefinitionService(IEventDefinitionRepository eventDefinitionRepository, IAwardConditionRepository awardConditionRepository) { _eventDefinitionRepository = eventDefinitionRepository; _awardConditionRepository = awardConditionRepository; } private readonly IEventDefinitionRepository _eventDefinitionRepository; private readonly IAwardConditionRepository _awardConditionRepository; public async Task GetEventDefinition(string eventDefinitionID) { if (StaticEvents.ContainsKey(eventDefinitionID)) return StaticEvents[eventDefinitionID]; return await _eventDefinitionRepository.Get(eventDefinitionID); } public async Task> GetAll() { var merged = StaticEvents.Select(x => x.Value).ToList(); merged.AddRange(await _eventDefinitionRepository.GetAll()); return merged.OrderBy(x => x.EventDefinitionID).ToList(); } public async Task Create(EventDefinition eventDefinition) { await _eventDefinitionRepository.Create(eventDefinition); } public async Task Delete(string eventDefinitionID) { await _awardConditionRepository.DeleteConditionsByEventDefinitionID(eventDefinitionID); _eventDefinitionRepository.Delete(eventDefinitionID); } } ================================================ FILE: src/PopForums/ScoringGame/EventPublisher.cs ================================================ namespace PopForums.ScoringGame; public interface IEventPublisher { Task ProcessEvent(string feedMessage, User user, string eventDefinitionID, bool overridePublishToActivityFeed); Task ProcessManualEvent(string feedMessage, User user, int pointValue); } public class EventPublisher : IEventPublisher { public EventPublisher(IEventDefinitionService eventDefinitionService, IPointLedgerRepository pointLedgerRepository, IFeedService feedService, IAwardCalculator awardCalculator, IProfileService profileService) { _eventDefinitionService = eventDefinitionService; _pointLedgerRepository = pointLedgerRepository; _feedService = feedService; _awardCalculator = awardCalculator; _profileService = profileService; } private readonly IEventDefinitionService _eventDefinitionService; private readonly IPointLedgerRepository _pointLedgerRepository; private readonly IFeedService _feedService; private readonly IAwardCalculator _awardCalculator; private readonly IProfileService _profileService; public async Task ProcessEvent(string feedMessage, User user, string eventDefinitionID, bool overridePublishToActivityFeed) { var timeStamp = DateTime.UtcNow; var eventDefinition = await _eventDefinitionService.GetEventDefinition(eventDefinitionID); var ledgerEntry = new PointLedgerEntry { UserID = user.UserID, EventDefinitionID = eventDefinitionID, Points = eventDefinition.PointValue, TimeStamp = timeStamp }; await _pointLedgerRepository.RecordEntry(ledgerEntry); await _profileService.UpdatePointTotal(user); if (eventDefinition.IsPublishedToFeed && !overridePublishToActivityFeed) { await _feedService.PublishToFeed(user, feedMessage, eventDefinition.PointValue, timeStamp); } await _awardCalculator.QueueCalculation(user, eventDefinition); } public async Task ProcessManualEvent(string feedMessage, User user, int pointValue) { var timeStamp = DateTime.UtcNow; var eventDefinition = new EventDefinition { EventDefinitionID = "Manual", PointValue = pointValue }; var ledgerEntry = new PointLedgerEntry { UserID = user.UserID, EventDefinitionID = eventDefinition.EventDefinitionID, Points = eventDefinition.PointValue, TimeStamp = timeStamp }; await _pointLedgerRepository.RecordEntry(ledgerEntry); await _profileService.UpdatePointTotal(user); await _feedService.PublishToFeed(user, feedMessage, eventDefinition.PointValue, timeStamp); } } ================================================ FILE: src/PopForums/ScoringGame/PointLedgerEntry.cs ================================================ namespace PopForums.ScoringGame; public class PointLedgerEntry { public int UserID { get; set; } public string EventDefinitionID { get; set; } public int Points { get; set; } public DateTime TimeStamp { get; set; } } ================================================ FILE: src/PopForums/ScoringGame/UserAward.cs ================================================ namespace PopForums.ScoringGame; public class UserAward { public int UserAwardID { get; set; } public int UserID { get; set; } public string AwardDefinitionID { get; set; } public string Title { get; set; } public string Description { get; set; } public DateTime TimeStamp { get; set; } } ================================================ FILE: src/PopForums/ScoringGame/UserAwardService.cs ================================================ namespace PopForums.ScoringGame; public interface IUserAwardService { Task IssueAward(User user, AwardDefinition awardDefinition); Task IsAwarded(User user, AwardDefinition awardDefinition); Task> GetAwards(User user); } public class UserAwardService : IUserAwardService { public UserAwardService(IUserAwardRepository userAwardRepository, INotificationTunnel notificationTunnel, ITenantService tenantService) { _userAwardRepository = userAwardRepository; _notificationTunnel = notificationTunnel; _tenantService = tenantService; } private readonly IUserAwardRepository _userAwardRepository; private readonly INotificationTunnel _notificationTunnel; private readonly ITenantService _tenantService; public async Task IssueAward(User user, AwardDefinition awardDefinition) { await _userAwardRepository.IssueAward(user.UserID, awardDefinition.AwardDefinitionID, awardDefinition.Title, awardDefinition.Description, DateTime.UtcNow); var tenantID = _tenantService.GetTenant(); _notificationTunnel.SendNotificationForUserAward(awardDefinition.Title, user.UserID, tenantID); } public async Task IsAwarded(User user, AwardDefinition awardDefinition) { return await _userAwardRepository.IsAwarded(user.UserID, awardDefinition.AwardDefinitionID); } public async Task> GetAwards(User user) { return await _userAwardRepository.GetAwards(user.UserID); } } ================================================ FILE: src/PopForums/Services/BanService.cs ================================================ namespace PopForums.Services; public interface IBanService { Task BanIP(string ip); Task RemoveIPBan(string ip); Task> GetIPBans(); Task BanEmail(string email); Task RemoveEmailBan(string email); Task> GetEmailBans(); } public class BanService : IBanService { public BanService(IBanRepository banRepsoitory) { _banRepository = banRepsoitory; } private readonly IBanRepository _banRepository; public async Task BanIP(string ip) { await _banRepository.BanIP(ip.Trim()); } public async Task RemoveIPBan(string ip) { await _banRepository.RemoveIPBan(ip); } public async Task> GetIPBans() { return await _banRepository.GetIPBans(); } public async Task BanEmail(string email) { await _banRepository.BanEmail(email.Trim()); } public async Task RemoveEmailBan(string email) { await _banRepository.RemoveEmailBan(email); } public async Task> GetEmailBans() { return await _banRepository.GetEmailBans(); } } ================================================ FILE: src/PopForums/Services/CategoryService.cs ================================================ namespace PopForums.Services; public interface ICategoryService { Task Get(int categoryID); Task> GetAll(); Task Create(string title); Task Delete(int categoryID); Task Delete(Category category); Task UpdateTitle(int categoryID, string newTitle); Task UpdateTitle(Category category, string newTitle); Task MoveCategoryUp(int categoryID); Task MoveCategoryDown(int categoryID); Task MoveCategoryUp(Category category); Task MoveCategoryDown(Category category); } public class CategoryService : ICategoryService { public CategoryService(ICategoryRepository categoryRepository, IForumRepository forumRepository) { _categoryRepository = categoryRepository; _forumRepository = forumRepository; } private readonly ICategoryRepository _categoryRepository; private readonly IForumRepository _forumRepository; public async Task Get(int categoryID) { return await _categoryRepository.Get(categoryID); } public async Task> GetAll() { return await _categoryRepository.GetAll(); } public async Task Create(string title) { var newCategory = await _categoryRepository.Create(title, -2); await ChangeCategorySortOrder(null, 0); newCategory.SortOrder = 0; return newCategory; } public async Task Delete(int categoryID) { var category = await _categoryRepository.Get(categoryID); if (category == null) throw new Exception($"Category with ID {categoryID} does not exist."); await Delete(category); } public async Task Delete(Category category) { var forumResult = await _forumRepository.GetAll(); var forums = forumResult.Where(f => f.CategoryID == category.CategoryID); foreach (var forum in forums) await _forumRepository.UpdateCategoryAssociation(forum.ForumID, null); await _categoryRepository.Delete(category.CategoryID); } public async Task UpdateTitle(int categoryID, string newTitle) { var category = await _categoryRepository.Get(categoryID); if (category == null) throw new Exception($"Category with ID {categoryID} does not exist."); await UpdateTitle(category, newTitle); } public async Task UpdateTitle(Category category, string newTitle) { category.Title = newTitle; await _categoryRepository.Update(category); } private async Task ChangeCategorySortOrder(Category category, int change) { var categories = await GetAll(); if (category != null) categories.Single(c => c.CategoryID == category.CategoryID).SortOrder += change; var sorted = categories.OrderBy(c => c.SortOrder).ToList(); for (var i = 0; i < sorted.Count; i++) { sorted[i].SortOrder = i * 2; await _categoryRepository.Update(sorted[i]); } } public async Task MoveCategoryUp(int categoryID) { var category = await _categoryRepository.Get(categoryID); if (category == null) throw new Exception($"Can't move CategoryID {categoryID} up because it does not exist."); await MoveCategoryUp(category); } public async Task MoveCategoryDown(int categoryID) { var category = await _categoryRepository.Get(categoryID); if (category == null) throw new Exception($"Can't move CategoryID {categoryID} down because it does not exist."); await MoveCategoryDown(category); } public async Task MoveCategoryUp(Category category) { const int change = -3; await ChangeCategorySortOrder(category, change); } public async Task MoveCategoryDown(Category category) { const int change = 3; await ChangeCategorySortOrder(category, change); } } ================================================ FILE: src/PopForums/Services/ClaimsToRoleMapper.cs ================================================ using System.Security.Claims; namespace PopForums.Services; public interface IClaimsToRoleMapper { Task MapRoles(User user, IEnumerable claims); } public class ClaimsToRoleMapper : IClaimsToRoleMapper { private readonly IConfig _config; private readonly IRoleRepository _roleRepository; public ClaimsToRoleMapper(IConfig config, IRoleRepository roleRepository) { _config = config; _roleRepository = roleRepository; } public async Task MapRoles(User user, IEnumerable claims) { bool isAdmin = false, isModerator = false; var claimsList = claims.ToList(); var adminClaims = claimsList.Where(x => x.Type == _config.OAuthAdminClaimType).ToList(); if ((string.IsNullOrEmpty(_config.OAuthAdminClaimValue) && adminClaims.Any()) || adminClaims.Any(x => x.Value == _config.OAuthAdminClaimValue)) isAdmin = true; var modClaims = claimsList.Where(x => x.Type == _config.OAuthModeratorClaimType).ToList(); if ((string.IsNullOrEmpty(_config.OAuthModeratorClaimValue) && modClaims.Any()) || modClaims.Any(x => x.Value == _config.OAuthModeratorClaimValue)) isModerator = true; var newRoles = new List(); if (isAdmin) newRoles.Add(PermanentRoles.Admin); if (isModerator) newRoles.Add(PermanentRoles.Moderator); await _roleRepository.ReplaceUserRoles(user.UserID, newRoles.ToArray()); } } ================================================ FILE: src/PopForums/Services/CloseAgedTopicsWorker.cs ================================================ namespace PopForums.Services; public interface ICloseAgedTopicsWorker { void Execute(); } public class CloseAgedTopicsWorker(ITopicService topicService, IErrorLog errorLog) : ICloseAgedTopicsWorker { public async void Execute() { try { await topicService.CloseAgedTopics(); } catch (Exception exc) { errorLog.Log(exc, ErrorSeverity.Error); } } } ================================================ FILE: src/PopForums/Services/FavoriteTopicService.cs ================================================ namespace PopForums.Services; public interface IFavoriteTopicService { Task, PagerContext>> GetTopics(User user, int pageIndex); Task IsTopicFavorite(int userID, int topicID); Task AddFavoriteTopic(User user, Topic topic); Task RemoveFavoriteTopic(User user, Topic topic); } public class FavoriteTopicService : IFavoriteTopicService { public FavoriteTopicService(ISettingsManager settingsManager, IFavoriteTopicsRepository favoriteTopicRepository) { _settingsManager = settingsManager; _favoriteTopicRepository = favoriteTopicRepository; } private readonly ISettingsManager _settingsManager; private readonly IFavoriteTopicsRepository _favoriteTopicRepository; public async Task, PagerContext>> GetTopics(User user, int pageIndex) { var pageSize = _settingsManager.Current.TopicsPerPage; var startRow = ((pageIndex - 1) * pageSize) + 1; var topics = await _favoriteTopicRepository.GetFavoriteTopics(user.UserID, startRow, pageSize); var topicCount = await _favoriteTopicRepository.GetFavoriteTopicCount(user.UserID); var totalPages = Convert.ToInt32(Math.Ceiling(Convert.ToDouble(topicCount) / Convert.ToDouble(pageSize))); var pagerContext = new PagerContext { PageCount = totalPages, PageIndex = pageIndex, PageSize = pageSize }; return Tuple.Create(topics, pagerContext); } public async Task IsTopicFavorite(int userID, int topicID) { return await _favoriteTopicRepository.IsTopicFavorite(userID, topicID); } public async Task AddFavoriteTopic(User user, Topic topic) { await _favoriteTopicRepository.AddFavoriteTopic(user.UserID, topic.TopicID); } public async Task RemoveFavoriteTopic(User user, Topic topic) { await _favoriteTopicRepository.RemoveFavoriteTopic(user.UserID, topic.TopicID); } } ================================================ FILE: src/PopForums/Services/ForumPermissionService.cs ================================================ namespace PopForums.Services; public interface IForumPermissionService { Task GetPermissionContext(Forum forum, User user); Task GetPermissionContext(Forum forum, User user, Topic topic); } public class ForumPermissionService : IForumPermissionService { private readonly IForumRepository _forumRepository; public ForumPermissionService(IForumRepository forumRepository) { _forumRepository = forumRepository; } public async Task GetPermissionContext(Forum forum, User user) { return await GetPermissionContext(forum, user, null); } public async Task GetPermissionContext(Forum forum, User user, Topic topic) { var context = new ForumPermissionContext { DenialReason = string.Empty }; var viewRestrictionRoles = await _forumRepository.GetForumViewRoles(forum.ForumID); var postRestrictionRoles = await _forumRepository.GetForumPostRoles(forum.ForumID); // view if (viewRestrictionRoles.Count == 0) context.UserCanView = true; else { context.UserCanView = false; if (user != null && viewRestrictionRoles.Where(user.IsInRole).Any()) context.UserCanView = true; } // post if (user == null || !context.UserCanView) { context.UserCanPost = false; context.DenialReason = Resources.LoginToPost; } else if (!user.IsApproved) { context.DenialReason += "You can't post until you have verified your account. "; context.UserCanPost = false; } else { if (postRestrictionRoles.Count == 0) context.UserCanPost = true; else { if (postRestrictionRoles.Where(user.IsInRole).Any()) context.UserCanPost = true; else { context.DenialReason += Resources.ForumNoPost + ". "; context.UserCanPost = false; } } } if (topic != null && topic.IsClosed) { context.UserCanPost = false; context.DenialReason = Resources.Closed + ". "; } if (topic != null && topic.IsDeleted) { if (user == null || !user.IsInRole(PermanentRoles.Moderator)) context.UserCanView = false; context.DenialReason += "Topic is deleted. "; } if (forum.IsArchived) { context.UserCanPost = false; context.DenialReason += Resources.Archived + ". "; } // moderate context.UserCanModerate = false; if (user != null && (user.IsInRole(PermanentRoles.Admin) || user.IsInRole(PermanentRoles.Moderator))) context.UserCanModerate = true; return context; } } ================================================ FILE: src/PopForums/Services/ForumService.cs ================================================ namespace PopForums.Services; public interface IForumService { Task Get(int forumID); Task Get(string urlName); Task Create(int? categoryID, string title, string description, bool isVisible, bool isArchived, int sortOrder, string forumAdapterName, bool isQAForum); Task UpdateLast(Forum forum); Task UpdateLast(Forum forum, DateTime lastTime, string lastName); void UpdateCounts(Forum forum); Task GetCategorizedForumContainer(); Task> GetCategoryContainersWithForums(); Task GetCategorizedForumContainerFilteredForUser(User user); Task> GetNonViewableForumIDs(User user); Task Update(Forum forum, int? categoryID, string title, string description, bool isVisible, bool isArchived, string forumAdapterName, bool isQAForum); Task MoveForumUp(int forumID); Task MoveForumDown(int forumID); Task> GetForumPostRoles(Forum forum); Task> GetForumViewRoles(Forum forum); Dictionary GetAllForumTitles(); Task, PagerContext>> GetRecentTopics(User user, bool includeDeleted, int pageIndex); Task GetAggregateTopicCount(); Task GetAggregatePostCount(); Task> GetViewableForumIDsFromViewRestrictedForums(User user); TopicContainerForQA MapTopicContainerForQA(TopicContainer topicContainer); Task ModifyForumRoles(ModifyForumRolesContainer container); } public class ForumService : IForumService { public ForumService(IForumRepository forumRepository, ITopicRepository topicRepository, ICategoryRepository categoryRepository, ISettingsManager settingsManager, ILastReadService lastReadService) { _forumRepository = forumRepository; _topicRepository = topicRepository; _categoryRepository = categoryRepository; _settingsManager = settingsManager; _lastReadService = lastReadService; } private readonly IForumRepository _forumRepository; private readonly ITopicRepository _topicRepository; private readonly ICategoryRepository _categoryRepository; private readonly ISettingsManager _settingsManager; private readonly ILastReadService _lastReadService; public async Task Get(int forumID) { return await _forumRepository.Get(forumID); } public async Task Get(string urlName) { return await _forumRepository.Get(urlName); } public async Task Create(int? categoryID, string title, string description, bool isVisible, bool isArchived, int sortOrder, string forumAdapterName, bool isQAForum) { var urlName = title.ToUniqueUrlName(await _forumRepository.GetUrlNamesThatStartWith(title.ToUrlName())); var forum = await _forumRepository.Create(categoryID, title, description, isVisible, isArchived, sortOrder, urlName, forumAdapterName, isQAForum); forum.UrlName = urlName; var forums = await _forumRepository.GetAll(); await SortAndUpdateForums(forums.ToList()); return forum; } public async Task Update(Forum forum, int? categoryID, string title, string description, bool isVisible, bool isArchived, string forumAdapterName, bool isQAForum) { var urlName = forum.UrlName; if (forum.Title != title) urlName = title.ToUniqueUrlName(await _forumRepository.GetUrlNamesThatStartWith(title.ToUrlName())); await _forumRepository.Update(forum.ForumID, categoryID, title, description, isVisible, isArchived, urlName, forumAdapterName, isQAForum); } public async Task UpdateLast(Forum forum) { var topic = await _topicRepository.GetLastUpdatedTopic(forum.ForumID); if (topic != null) await UpdateLast(forum, topic.LastPostTime, topic.LastPostName); else await UpdateLast(forum, new DateTime(2000, 1, 1), String.Empty); } public async Task UpdateLast(Forum forum, DateTime lastTime, string lastName) { await _forumRepository.UpdateLastTimeAndUser(forum.ForumID, lastTime, lastName); } public void UpdateCounts(Forum forum) { new Thread(() => { var topicCount = _topicRepository.GetTopicCount(forum.ForumID, false).Result; var postCount = _topicRepository.GetPostCount(forum.ForumID, false).Result; _forumRepository.UpdateTopicAndPostCounts(forum.ForumID, topicCount, postCount); }).Start(); } public async Task GetCategorizedForumContainer() { var forums = _forumRepository.GetAll(); var categories = await _categoryRepository.GetAll(); var container = new CategorizedForumContainer(categories, forums.Result); container.ForumTitle = _settingsManager.Current.ForumTitle; return container; } public async Task> GetCategoryContainersWithForums() { var containers = new List(); var forumResult = await _forumRepository.GetAll(); var forums = forumResult.ToList(); var categories = await _categoryRepository.GetAll(); var orderedCategories = categories.OrderBy(x => x.SortOrder); var uncategorized = forums.Where(x => !x.CategoryID.HasValue).OrderBy(x => x.SortOrder).ToList(); if (uncategorized.Count > 0) containers.Add(new CategoryContainerWithForums {Category = new Category { Title = Resources.ForumsUncat }, Forums = uncategorized}); foreach (var item in orderedCategories) { var filteredForums = forums.Where(x => x.CategoryID == item.CategoryID).OrderBy(x => x.SortOrder); containers.Add(new CategoryContainerWithForums { Category = item, Forums = filteredForums }); } return containers; } public async Task> GetViewableForumIDsFromViewRestrictedForums(User user) { var nonViewableForumIDs = await GetNonViewableForumIDs(user); var forums = await _forumRepository.GetAllVisible(); var noViewRestrictionForums = forums.Where(f => !nonViewableForumIDs.Contains(f.ForumID)); return noViewRestrictionForums.Select(x => x.ForumID).ToList(); } public async Task GetCategorizedForumContainerFilteredForUser(User user) { var nonViewableForumIDs = await GetNonViewableForumIDs(user); var unfilteredForums = await _forumRepository.GetAllVisible(); var forums = unfilteredForums.Where(f => !nonViewableForumIDs.Contains(f.ForumID)); var categories = await _categoryRepository.GetAll(); var container = new CategorizedForumContainer(categories, forums); await _lastReadService.GetForumReadStatus(user, container); container.ForumTitle = _settingsManager.Current.ForumTitle; return container; } public async Task> GetNonViewableForumIDs(User user) { var forumsWithRestrictions = await _forumRepository.GetForumViewRestrictionRoleGraph(); var nonViewableForums = new List(); foreach (var item in forumsWithRestrictions.Where(f => f.Value.Count > 0)) { if (user == null) nonViewableForums.Add(item.Key); else if (!user.Roles.Intersect(item.Value).Any()) nonViewableForums.Add(item.Key); } return nonViewableForums; } private async Task ChangeForumSortOrder(Forum forum, int change) { var forums = await _forumRepository.GetForumsInCategory(forum.CategoryID); forums.Single(c => c.ForumID == forum.ForumID).SortOrder += change; await SortAndUpdateForums(forums); } private async Task SortAndUpdateForums(IEnumerable forums) { var sorted = forums.OrderBy(f => f.SortOrder).ToList(); for (var i = 0; i < sorted.Count; i++) { var correctedForum = sorted[i]; correctedForum.SortOrder = i * 2; await _forumRepository.UpdateSortOrder(correctedForum.ForumID, correctedForum.SortOrder); } } public async Task MoveForumUp(int forumID) { var forum = await _forumRepository.Get(forumID); if (forum == null) throw new Exception($"Forum {forumID} doesn't exist, can't move it up."); const int change = -3; await ChangeForumSortOrder(forum, change); } public async Task MoveForumDown(int forumID) { var forum = await _forumRepository.Get(forumID); if (forum == null) throw new Exception($"Forum {forumID} doesn't exist, can't move it down."); const int change = 3; await ChangeForumSortOrder(forum, change); } public async Task> GetForumPostRoles(Forum forum) { return await _forumRepository.GetForumPostRoles(forum.ForumID); } public async Task> GetForumViewRoles(Forum forum) { return await _forumRepository.GetForumViewRoles(forum.ForumID); } public Dictionary GetAllForumTitles() { return _forumRepository.GetAllForumTitles(); } public async Task, PagerContext>> GetRecentTopics(User user, bool includeDeleted, int pageIndex) { var nonViewableForumIDs = await GetNonViewableForumIDs(user); var pageSize = _settingsManager.Current.TopicsPerPage; var startRow = ((pageIndex - 1) * pageSize) + 1; var topics = await _topicRepository.Get(includeDeleted, nonViewableForumIDs, startRow, pageSize); var topicCount = await _topicRepository.GetTopicCount(includeDeleted, nonViewableForumIDs); var totalPages = Convert.ToInt32(Math.Ceiling(Convert.ToDouble(topicCount) / Convert.ToDouble(pageSize))); var pagerContext = new PagerContext { PageCount = totalPages, PageIndex = pageIndex, PageSize = pageSize }; return Tuple.Create(topics, pagerContext); } public async Task GetAggregateTopicCount() { return await _forumRepository.GetAggregateTopicCount(); } public async Task GetAggregatePostCount() { return await _forumRepository.GetAggregatePostCount(); } public TopicContainerForQA MapTopicContainerForQA(TopicContainer topicContainer) { var result = new TopicContainerForQA { Forum = topicContainer.Forum, Topic = topicContainer.Topic, Posts = topicContainer.Posts, PagerContext = topicContainer.PagerContext, PermissionContext = topicContainer.PermissionContext, Signatures = topicContainer.Signatures, Avatars = topicContainer.Avatars, VotedPostIDs = topicContainer.VotedPostIDs, TopicState = topicContainer.TopicState }; try { var questionPost = result.Posts.Single(x => x.IsFirstInTopic); var questionComments = result.Posts.Where(x => x.ParentPostID == questionPost.PostID).ToList(); result.QuestionPostWithComments = new PostWithChildren { Post = questionPost, Children = questionComments, LastReadTime = topicContainer.LastReadTime }; } catch (InvalidOperationException) { throw new InvalidOperationException($"There is no post marked as FirstInTopic for TopicID {topicContainer.Topic.TopicID}."); } var answers = result.Posts.Where(x => !x.IsFirstInTopic && (x.ParentPostID == 0)).OrderByDescending(x => x.Votes).ThenByDescending(x => x.PostTime).ToList(); if (topicContainer.Topic.AnswerPostID.HasValue) { var acceptedAnswer = answers.SingleOrDefault(x => x.PostID == topicContainer.Topic.AnswerPostID.Value); if (acceptedAnswer != null) { answers.Remove(acceptedAnswer); answers.Insert(0, acceptedAnswer); } } result.AnswersWithComments = new List(); foreach (var item in answers) { var comments = result.Posts.Where(x => x.ParentPostID == item.PostID).ToList(); result.AnswersWithComments.Add(new PostWithChildren { Post = item, Children = comments, LastReadTime = topicContainer.LastReadTime }); } return result; } public async Task ModifyForumRoles(ModifyForumRolesContainer container) { var forum = await Get(container.ForumID); if (forum == null) throw new Exception($"ForumID {container.ForumID} not found."); switch (container.ModifyType) { case ModifyForumRolesType.AddPost: await _forumRepository.AddPostRole(forum.ForumID, container.Role); break; case ModifyForumRolesType.RemovePost: await _forumRepository.RemovePostRole(forum.ForumID, container.Role); break; case ModifyForumRolesType.AddView: await _forumRepository.AddViewRole(forum.ForumID, container.Role); break; case ModifyForumRolesType.RemoveView: await _forumRepository.RemoveViewRole(forum.ForumID, container.Role); break; case ModifyForumRolesType.RemoveAllPost: await _forumRepository.RemoveAllPostRoles(forum.ForumID); break; case ModifyForumRolesType.RemoveAllView: await _forumRepository.RemoveAllViewRoles(forum.ForumID); break; default: throw new Exception("ModifyForumRoles doesn't know what to do."); } } } ================================================ FILE: src/PopForums/Services/IPHistoryService.cs ================================================ namespace PopForums.Services; public interface IIPHistoryService { Task> GetHistory(string ip, DateTime start, DateTime end); } public class IPHistoryService : IIPHistoryService { public IPHistoryService(IPostService postService, ISecurityLogService securityLogService) { _postService = postService; _securityLogService = securityLogService; } private readonly IPostService _postService; private readonly ISecurityLogService _securityLogService; public async Task> GetHistory(string ip, DateTime start, DateTime end) { var list = new List(); list.AddRange(await _postService.GetIPHistory(ip, start, end)); list.AddRange(await _securityLogService.GetIPHistory(ip, start, end)); return list.OrderBy(i => i.EventTime).ToList(); } } ================================================ FILE: src/PopForums/Services/ITopicViewCountService.cs ================================================ namespace PopForums.Services; public interface ITopicViewCountService { Task ProcessView(Topic topic); void SetViewedTopic(Topic topic); } ================================================ FILE: src/PopForums/Services/IUserRetrievalShim.cs ================================================ namespace PopForums.Services; public interface IUserRetrievalShim { User GetUser(); Profile GetProfile(); } ================================================ FILE: src/PopForums/Services/IgnoreService.cs ================================================ namespace PopForums.Services; public interface IIgnoreService { Task> GetIgnoreList(int userID); Task AddIgnore(int userID, int ignoreUserID); Task DeleteIgnore(int userID, int ignoreUserID); Task> GetIgnoredUserIdsInList(User user, List posts); } public class IgnoreService(IIgnoreRepository ignoreRepository) : IIgnoreService { public async Task> GetIgnoreList(int userID) { return await ignoreRepository.GetIgnoreList(userID); } public async Task AddIgnore(int userID, int ignoreUserID) { await ignoreRepository.AddIgnore(userID, ignoreUserID); } public async Task DeleteIgnore(int userID, int ignoreUserID) { await ignoreRepository.DeleteIgnore(userID, ignoreUserID); } public async Task> GetIgnoredUserIdsInList(User user, List posts) { if (user == null) return new List(); var userIDs = posts.Select(p => p.UserID).Distinct().ToList(); return await ignoreRepository.GetIgnoredUserIdsInList(user.UserID, userIDs); } } ================================================ FILE: src/PopForums/Services/ImageService.cs ================================================ using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Jpeg; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; namespace PopForums.Services; public interface IImageService { Task IsUserImageApproved(int userImageID); [Obsolete("Use GetAvatarImageStream(int) instead.")] Task GetAvatarImageData(int userAvatarID); Task GetAvatarImageStream(int userAvatarID); [Obsolete("Use GetUserImageStream(int) instead.")] Task GetUserImageData(int userImageID); Task GetUserImageStream(int userImageID); Task GetAvatarImageLastModification(int userAvatarID); Task GetUserImageLastModifcation(int userImageID); byte[] ConstrainResize(byte[] bytes, int maxWidth, int maxHeight, int qualityLevel, bool cropInsteadOfConstrain); Task> GetUnapprovedUserImages(); Task ApproveUserImage(int userImageID); Task DeleteUserImage(int userImageID); Task GetUserImage(int userImageID); Task GetUnapprovedUserImageContainer(); } public class ImageService : IImageService { public ImageService(IUserAvatarRepository userAvatarRepository, IUserImageRepository userImageRepository, IProfileService profileService, IUserRepository userRepository, ISettingsManager settingsManager) { _userAvatarRepository = userAvatarRepository; _userImageRepository = userImageRepository; _profileService = profileService; _userRepository = userRepository; _settingsManager = settingsManager; } private readonly IUserAvatarRepository _userAvatarRepository; private readonly IUserImageRepository _userImageRepository; private readonly IProfileService _profileService; private readonly IUserRepository _userRepository; private readonly ISettingsManager _settingsManager; public async Task IsUserImageApproved(int userImageID) { return await _userImageRepository.IsUserImageApproved(userImageID); } public async Task GetUserImage(int userImageID) { return await _userImageRepository.Get(userImageID); } public async Task ApproveUserImage(int userImageID) { await _userImageRepository.ApproveUserImage(userImageID); } public async Task DeleteUserImage(int userImageID) { var userImage = await _userImageRepository.Get(userImageID); if (userImage != null) await _profileService.SetCurrentImageIDToNull(userImage.UserID); await _userImageRepository.DeleteUserImage(userImageID); } [Obsolete("Use GetAvatarImageStream(int) instead.")] public async Task GetAvatarImageData(int userAvatarID) { return await _userAvatarRepository.GetImageData(userAvatarID); } public async Task GetAvatarImageStream(int userAvatarID) { return await _userAvatarRepository.GetImageStream(userAvatarID); } [Obsolete("Use GetUserImageStream(int) instead.")] public async Task GetUserImageData(int userImageID) { return await _userImageRepository.GetImageData(userImageID); } public async Task GetUserImageStream(int userImageID) { return await _userImageRepository.GetImageStream(userImageID); } public async Task> GetUnapprovedUserImages() { return await _userImageRepository.GetUnapprovedUserImages(); } public async Task GetAvatarImageLastModification(int userAvatarID) { return await _userAvatarRepository.GetLastModificationDate(userAvatarID); } public async Task GetUserImageLastModifcation(int userImageID) { return await _userImageRepository.GetLastModificationDate(userImageID); } public byte[] ConstrainResize(byte[] bytes, int maxWidth, int maxHeight, int qualityLevel, bool cropInsteadOfConstrain) { if (bytes == null) throw new Exception("Bytes parameter is null."); using (var stream = new MemoryStream(bytes)) using (var image = Image.Load(stream)) using (var output = new MemoryStream()) { if (image.Height <= maxHeight && image.Width <= maxWidth) return bytes; var options = new ResizeOptions { Size = new Size(maxWidth, maxHeight), Mode = cropInsteadOfConstrain ? ResizeMode.Crop : ResizeMode.Max }; image.Mutate(x => x .Resize(options) .GaussianSharpen(0.5f)); image.Save(output, new JpegEncoder { Quality = qualityLevel }); return output.ToArray(); } } public async Task GetUnapprovedUserImageContainer() { var isNewUserImageApproved = _settingsManager.Current.IsNewUserImageApproved; var dictionary = new Dictionary(); var unapprovedImages = await GetUnapprovedUserImages(); var users = await _userRepository.GetUsersFromIDs(unapprovedImages.Select(i => i.UserID).ToList()); var container = new UserImageApprovalContainer { Unapproved = new List(), IsNewUserImageApproved = isNewUserImageApproved }; foreach (var image in unapprovedImages) { container.Unapproved.Add(new UserImagePair { User = users.Single(u => u.UserID == image.UserID), UserImage = image }); dictionary.Add(image, users.Single(u => u.UserID == image.UserID)); } return container; } } ================================================ FILE: src/PopForums/Services/LastReadService.cs ================================================ namespace PopForums.Services; public interface ILastReadService { Task MarkForumRead(User user, Forum forum); Task MarkAllForumsRead(User user); Task MarkTopicRead(User user, Topic topic); Task GetForumReadStatus(User user, CategorizedForumContainer container); Task GetTopicReadStatus(User user, PagedTopicContainer container); Task GetFirstUnreadPost(User user, Topic topic); Task GetTopicReadStatus(User user, Topic topic); Task GetForumReadStatus(User user, Forum forum); Task GetLastReadTime(User user, Topic topic); } public class LastReadService : ILastReadService { public LastReadService(ILastReadRepository lastReadRepository, IPostRepository postRepository) { _lastReadRepository = lastReadRepository; _postRepository = postRepository; } private readonly ILastReadRepository _lastReadRepository; private readonly IPostRepository _postRepository; public async Task MarkForumRead(User user, Forum forum) { if (user == null) throw new ArgumentNullException("user"); if (forum == null) throw new ArgumentNullException("forum"); await _lastReadRepository.SetForumRead(user.UserID, forum.ForumID, DateTime.UtcNow); await _lastReadRepository.DeleteTopicReadsInForum(user.UserID, forum.ForumID); } public async Task MarkAllForumsRead(User user) { if (user == null) throw new ArgumentNullException("user"); await _lastReadRepository.SetAllForumsRead(user.UserID, DateTime.UtcNow); await _lastReadRepository.DeleteAllTopicReads(user.UserID); } public async Task MarkTopicRead(User user, Topic topic) { if (user == null) throw new ArgumentNullException("user"); if (topic == null) throw new ArgumentNullException("topic"); await _lastReadRepository.SetTopicRead(user.UserID, topic.TopicID, DateTime.UtcNow); } public async Task GetForumReadStatus(User user, CategorizedForumContainer container) { Dictionary lastReads = null; if (user != null) lastReads = await _lastReadRepository.GetLastReadTimesForForums(user.UserID); foreach (var forum in container.AllForums) { var status = ReadStatus.NoNewPosts; if (lastReads != null && lastReads.ContainsKey(forum.ForumID)) if (forum.LastPostTime > lastReads[forum.ForumID]) status = ReadStatus.NewPosts; if (lastReads != null && !lastReads.ContainsKey(forum.ForumID)) status = ReadStatus.NewPosts; if (forum.IsArchived) status |= ReadStatus.Closed; container.ReadStatusLookup.Add(forum.ForumID, status); } } public async Task GetTopicReadStatus(User user, Topic topic) { if (user != null) { return await _lastReadRepository.GetLastReadTimeForTopic(user.UserID, topic.TopicID); } return null; } public async Task GetForumReadStatus(User user, Forum forum) { if (user != null) { return await _lastReadRepository.GetLastReadTimesForForum(user.UserID, forum.ForumID); } return null; } public async Task GetTopicReadStatus(User user, PagedTopicContainer container) { Dictionary lastForumReads = null; Dictionary lastTopicReads = null; if (user != null) { lastForumReads = await _lastReadRepository.GetLastReadTimesForForums(user.UserID); lastTopicReads = await _lastReadRepository.GetLastReadTimesForTopics(user.UserID, container.Topics.Select(t => t.TopicID)); } foreach (var topic in container.Topics) { var status = new ReadStatus(); if (topic.IsClosed) status |= ReadStatus.Closed; else status |= ReadStatus.Open; if (topic.IsPinned) status |= ReadStatus.Pinned; else status |= ReadStatus.NotPinned; if (lastForumReads == null) status |= ReadStatus.NoNewPosts; else { var lastRead = DateTime.MinValue; if (lastForumReads.ContainsKey(topic.ForumID)) lastRead = lastForumReads[topic.ForumID]; if (lastTopicReads.ContainsKey(topic.TopicID) && lastTopicReads[topic.TopicID] > lastRead) lastRead = lastTopicReads[topic.TopicID]; if (topic.LastPostTime > lastRead) status |= ReadStatus.NewPosts; else status |= ReadStatus.NoNewPosts; } container.ReadStatusLookup.Add(topic.TopicID, status); } } public async Task GetFirstUnreadPost(User user, Topic topic) { if (topic == null) throw new ArgumentException("Can't use a null topic.", "topic"); var includeDeleted = false; if (user != null && user.IsInRole(PermanentRoles.Moderator)) includeDeleted = true; var ids = await _postRepository.GetPostIDsWithTimes(topic.TopicID, includeDeleted); var postIDs = ids.Select(d => new { PostID = d.Key, PostTime = d.Value }).ToList(); if (user == null) return await _postRepository.Get(postIDs[0].PostID); var lastRead = await _lastReadRepository.GetLastReadTimeForTopic(user.UserID, topic.TopicID); if (!lastRead.HasValue) lastRead = await _lastReadRepository.GetLastReadTimesForForum(user.UserID, topic.ForumID); if (!lastRead.HasValue || !postIDs.Any(p => p.PostTime > lastRead.Value)) return await _postRepository.Get(postIDs[0].PostID); var firstNew = postIDs.First(p => p.PostTime > lastRead.Value); return await _postRepository.Get(firstNew.PostID); } public async Task GetLastReadTime(User user, Topic topic) { var lastRead = await _lastReadRepository.GetLastReadTimeForTopic(user.UserID, topic.TopicID); if (!lastRead.HasValue) lastRead = await _lastReadRepository.GetLastReadTimesForForum(user.UserID, topic.ForumID); return lastRead; } } ================================================ FILE: src/PopForums/Services/MailingListService.cs ================================================ namespace PopForums.Services; public interface IMailingListService { void MailUsers(string subject, string body, string htmlBody, Func unsubscribeLinkGenerator); } public class MailingListService : IMailingListService { public MailingListService(IUserService userService, IMailingListComposer mailingListComposer, IErrorLog errorLog) { _userService = userService; _mailingListComposer = mailingListComposer; _errorLog = errorLog; } private readonly IUserService _userService; private readonly IMailingListComposer _mailingListComposer; private readonly IErrorLog _errorLog; private static Thread _mailWorker; public void MailUsers(string subject, string body, string htmlBody, Func unsubscribeLinkGenerator) { _mailWorker = new Thread(() => { var users = _userService.GetSubscribedUsers().Result; foreach (var user in users) { var unsubLink = unsubscribeLinkGenerator(user); try { _mailingListComposer.ComposeAndQueue(user, subject, body, htmlBody, unsubLink); } catch (Exception exc) { _errorLog.Log(exc, ErrorSeverity.Error, "UserID: " + user.UserID); } } }); _mailWorker.Start(); } } ================================================ FILE: src/PopForums/Services/ModerationLogService.cs ================================================ namespace PopForums.Services; public interface IModerationLogService { Task LogTopic(User user, ModerationType moderationType, Topic topic, Forum forum); Task LogTopic(User user, ModerationType moderationType, Topic topic, Forum forum, string comment); Task LogTopic(ModerationType moderationType, int topicID); Task LogPost(User user, ModerationType moderationType, Post post, string comment, string oldText); Task> GetLog(DateTime start, DateTime end); Task> GetLog(Topic topic, bool excludePostEntries); Task> GetLog(Post post); } public class ModerationLogService : IModerationLogService { public ModerationLogService(IModerationLogRepository moderationLogRepo) { _moderationLogRepo = moderationLogRepo; } private readonly IModerationLogRepository _moderationLogRepo; public async Task LogTopic(User user, ModerationType moderationType, Topic topic, Forum forum) { await LogTopic(user, moderationType, topic, forum, String.Empty); } public async Task LogTopic(User user, ModerationType moderationType, Topic topic, Forum forum, string comment) { await _moderationLogRepo.Log(DateTime.UtcNow, user.UserID, user.Name, (int) moderationType, forum != null ? forum.ForumID : (int?)null, topic.TopicID, null, comment, string.Empty); } public async Task LogTopic(ModerationType moderationType, int topicID) { await _moderationLogRepo.Log(DateTime.UtcNow, 0, "System", (int)moderationType, null, topicID, null, string.Empty, string.Empty); } public async Task LogPost(User user, ModerationType moderationType, Post post, string comment, string oldText) { await _moderationLogRepo.Log(DateTime.UtcNow, user.UserID, user.Name, (int)moderationType, null, post.TopicID, post.PostID, comment, oldText); } public async Task> GetLog(DateTime start, DateTime end) { return await _moderationLogRepo.GetLog(start, end); } public async Task> GetLog(Topic topic, bool excludePostEntries) { return await _moderationLogRepo.GetLog(topic.TopicID, excludePostEntries); } public async Task> GetLog(Post post) { return await _moderationLogRepo.GetLog(post.PostID); } } ================================================ FILE: src/PopForums/Services/PostImageCleanupWorker.cs ================================================ namespace PopForums.Services; public interface IPostImageCleanupWorker { void Execute(); } public class PostImageCleanupWorker(IPostImageService postImageService, IErrorLog errorLog) : IPostImageCleanupWorker { public async void Execute() { try { await postImageService.DeleteOldPostImages(); } catch (Exception exc) { errorLog.Log(exc, ErrorSeverity.Error); } } } ================================================ FILE: src/PopForums/Services/PostImageService.cs ================================================ using System.Net.Mime; namespace PopForums.Services; public interface IPostImageService { bool ProcessImageIsOk(byte[] bytes, string contentType); Task PersistAndGetPayload(); Task GetWithoutData(string id); [Obsolete("Use the combination of GetWithoutData(int) and GetImageStream(int) instead.")] Task Get(string id); Task GetImageStream(string id); Task DeleteTempRecord(string id); Task DeleteTempRecords(string[] ids, string fullText); Task DeleteOldPostImages(); } public class PostImageService : IPostImageService { private readonly IImageService _imageService; private readonly IPostImageRepository _postImageRepository; private readonly IPostImageTempRepository _postImageTempRepository; private readonly ISettingsManager _settingsManager; private readonly ITenantService _tenantService; public PostImageService(IImageService imageService, IPostImageRepository postImageRepository, IPostImageTempRepository postImageTempRepository, ISettingsManager settingsManager, ITenantService tenantService) { _imageService = imageService; _postImageRepository = postImageRepository; _postImageTempRepository = postImageTempRepository; _settingsManager = settingsManager; _tenantService = tenantService; } private byte[] _bytes; private string _contentType; private bool _isOk; private const int HoursOldToDelete = 8; public bool ProcessImageIsOk(byte[] bytes, string contentType) { _contentType = contentType; _bytes = bytes; if (bytes.Length > _settingsManager.Current.PostImageMaxkBytes * 1024) { _isOk = false; return false; } if (contentType != MediaTypeNames.Image.Jpeg && contentType != MediaTypeNames.Image.Gif && contentType != "image/png") { _isOk = false; return false; } _bytes = _imageService.ConstrainResize(bytes, _settingsManager.Current.PostImageMaxWidth, _settingsManager.Current.PostImageMaxHeight, 60, false); _isOk = true; return true; } public async Task PersistAndGetPayload() { if (_bytes == null || string.IsNullOrWhiteSpace(_contentType)) throw new Exception($"No image processed or missing content type. Call {nameof(ProcessImageIsOk)} first."); if (!_isOk) throw new Exception($"You can't persist an image that was not Ok after calling {nameof(ProcessImageIsOk)}."); var payload = await _postImageRepository.Persist(_bytes, _contentType); var tenantID = _tenantService.GetTenant(); await _postImageTempRepository.Save(Guid.Parse(payload.ID), DateTime.UtcNow, tenantID); return payload; } public async Task GetWithoutData(string id) { var postImageSansData = await _postImageRepository.GetWithoutData(id); return postImageSansData; } [Obsolete("Use the combination of GetWithoutData(int) and GetImageStream(int) instead.")] public async Task Get(string id) { var postImageSansData = await _postImageRepository.Get(id); return postImageSansData; } public async Task GetImageStream(string id) { return await _postImageRepository.GetImageStream(id); } public async Task DeleteTempRecord(string id) { var guid = Guid.Parse(id); await _postImageTempRepository.Delete(guid); } public async Task DeleteTempRecords(string[] ids, string fullText) { if (ids == null || ids.Length == 0) return; var filtered = new List(); foreach (var id in ids) if (fullText.Contains(id, StringComparison.OrdinalIgnoreCase)) filtered.Add(id); foreach (var id in filtered) await DeleteTempRecord(id); } public async Task DeleteOldPostImages() { var tenantID = _tenantService.GetTenant(); var olderThan = DateTime.UtcNow.AddHours(-HoursOldToDelete); var ids = await _postImageTempRepository.GetOld(olderThan); foreach (var id in ids) { await _postImageRepository.DeletePostImageData(id.ToString(), tenantID); await _postImageTempRepository.Delete(id); } } } ================================================ FILE: src/PopForums/Services/PostMasterService.cs ================================================ namespace PopForums.Services; public interface IPostMasterService { Task> EditPost(int postID, PostEdit postEdit, User editingUser, Func redirectLinkGenerator); Task> PostNewTopic(User user, NewPost newPost, string ip, string userUrl, Func topicLinkGenerator, Func redirectLinkGenerator); Task> PostReply(User user, int parentPostID, string ip, bool isFirstInTopic, NewPost newPost, DateTime postTime, Func topicLinkGenerator, string userUrl, Func postLinkGenerator, Func redirectLinkGenerator); } public class PostMasterService : IPostMasterService { private readonly ITextParsingService _textParsingService; private readonly ITopicRepository _topicRepository; private readonly IPostRepository _postRepository; private readonly IForumRepository _forumRepository; private readonly IProfileRepository _profileRepository; private readonly IEventPublisher _eventPublisher; private readonly IBroker _broker; private readonly ISearchIndexQueueRepository _searchIndexQueueRepository; private readonly ITenantService _tenantService; private readonly ISubscribedTopicsService _subscribedTopicsService; private readonly IModerationLogService _moderationLogService; private readonly IForumPermissionService _forumPermissionService; private readonly ISettingsManager _settingsManager; private readonly ITopicViewCountService _topicViewCountService; private readonly IPostImageService _postImageService; public PostMasterService(ITextParsingService textParsingService, ITopicRepository topicRepository, IPostRepository postRepository, IForumRepository forumRepository, IProfileRepository profileRepository, IEventPublisher eventPublisher, IBroker broker, ISearchIndexQueueRepository searchIndexQueueRepository, ITenantService tenantService, ISubscribedTopicsService subscribedTopicsService, IModerationLogService moderationLogService, IForumPermissionService forumPermissionService, ISettingsManager settingsManager, ITopicViewCountService topicViewCountService, IPostImageService postImageService) { _textParsingService = textParsingService; _topicRepository = topicRepository; _postRepository = postRepository; _forumRepository = forumRepository; _profileRepository = profileRepository; _eventPublisher = eventPublisher; _broker = broker; _searchIndexQueueRepository = searchIndexQueueRepository; _tenantService = tenantService; _subscribedTopicsService = subscribedTopicsService; _moderationLogService = moderationLogService; _forumPermissionService = forumPermissionService; _settingsManager = settingsManager; _topicViewCountService = topicViewCountService; _postImageService = postImageService; } public async Task> PostNewTopic(User user, NewPost newPost, string ip, string userUrl, Func topicLinkGenerator, Func redirectLinkGenerator) { if (user == null) return GetPostFailMessage(Resources.LoginToPost); var forum = await _forumRepository.Get(newPost.ItemID); if (forum == null) throw new Exception($"Forum {newPost.ItemID} not found"); var permissionContext = await _forumPermissionService.GetPermissionContext(forum, user); if (!permissionContext.UserCanView) return GetPostFailMessage(Resources.ForumNoView); if (!permissionContext.UserCanPost) return GetPostFailMessage(Resources.ForumNoPost); newPost.FullText = newPost.IsPlainText ? _textParsingService.ForumCodeToHtml(newPost.FullText) : _textParsingService.ClientHtmlToHtml(newPost.FullText); if (await IsNewPostDupeOrInTimeLimit(newPost.FullText, user)) return GetPostFailMessage(string.Format(Resources.PostWait, _settingsManager.Current.MinimumSecondsBetweenPosts)); if (string.IsNullOrWhiteSpace(newPost.FullText) || string.IsNullOrWhiteSpace(newPost.Title)) return GetPostFailMessage(Resources.PostEmpty); newPost.Title = _textParsingService.Censor(newPost.Title); var urlName = newPost.Title.ToUniqueUrlName(await _topicRepository.GetUrlNamesThatStartWith(newPost.Title.ToUrlName())); var timeStamp = DateTime.UtcNow; var topicID = await _topicRepository.Create(forum.ForumID, newPost.Title, 0, 0, user.UserID, user.Name, user.UserID, user.Name, timeStamp, false, false, false, urlName); var postID = await _postRepository.Create(topicID, 0, ip, true, newPost.IncludeSignature, user.UserID, user.Name, newPost.Title, newPost.FullText, timeStamp, false, user.Name, null, false, 0); await _forumRepository.UpdateLastTimeAndUser(forum.ForumID, timeStamp, user.Name); await _forumRepository.IncrementPostAndTopicCount(forum.ForumID); await _profileRepository.SetLastPostID(user.UserID, postID); var topic = new Topic { TopicID = topicID, ForumID = forum.ForumID, IsClosed = false, IsDeleted = false, IsPinned = false, LastPostName = user.Name, LastPostTime = timeStamp, LastPostUserID = user.UserID, ReplyCount = 0, StartedByName = user.Name, StartedByUserID = user.UserID, Title = newPost.Title, UrlName = urlName, ViewCount = 0 }; // {1} started a new topic: {3} var topicLink = topicLinkGenerator(topic); var message = string.Format(Resources.NewPostPublishMessage, userUrl, HtmlEncoder.Default.Encode(user.Name ?? string.Empty), topicLink, HtmlEncoder.Default.Encode(topic.Title ?? string.Empty)); var forumHasViewRestrictions = _forumRepository.GetForumViewRoles(forum.ForumID).Result.Count > 0; await _eventPublisher.ProcessEvent(message, user, EventDefinitionService.StaticEventIDs.NewTopic, forumHasViewRestrictions); await _eventPublisher.ProcessEvent(string.Empty, user, EventDefinitionService.StaticEventIDs.NewPost, true); forum = await _forumRepository.Get(forum.ForumID); _broker.NotifyForumUpdate(forum); _broker.NotifyTopicUpdate(topic, forum, topicLink); await _searchIndexQueueRepository.Enqueue(new SearchIndexPayload { TenantID = _tenantService.GetTenant(), TopicID = topic.TopicID, IsForRemoval = false }); _topicViewCountService.SetViewedTopic(topic); var profile = await _profileRepository.GetProfile(user.UserID); if (profile != null && profile.IsAutoFollowOnReply) await _subscribedTopicsService.AddSubscribedTopic(user.UserID, topicID); await _postImageService.DeleteTempRecords(newPost.PostImageIDs, newPost.FullText); var redirectLink = redirectLinkGenerator(topic); return new BasicServiceResponse {Data = topic, Message = null, Redirect = redirectLink, IsSuccessful = true}; } private BasicServiceResponse GetPostFailMessage(string message) { return new BasicServiceResponse {Data = null, Message = message, Redirect = null, IsSuccessful = false}; } private BasicServiceResponse GetReplyFailMessage(string message) { return new BasicServiceResponse { Data = null, Message = message, Redirect = null, IsSuccessful = false }; } public async Task> PostReply(User user, int parentPostID, string ip, bool isFirstInTopic, NewPost newPost, DateTime postTime, Func topicLinkGenerator, string userUrl, Func postLinkGenerator, Func redirectLinkGenerator) { if (user == null) return GetReplyFailMessage(Resources.LoginToPost); var topic = await _topicRepository.Get(newPost.ItemID); if (topic == null) return GetReplyFailMessage(Resources.TopicNotExist); if (topic.IsClosed) return GetReplyFailMessage(Resources.Closed); var forum = await _forumRepository.Get(topic.ForumID); if (forum == null) throw new Exception($"That's not good. Trying to reply to a topic orphaned from Forum {topic.ForumID}, which doesn't exist."); var permissionContext = await _forumPermissionService.GetPermissionContext(forum, user); if (!permissionContext.UserCanView) return GetReplyFailMessage(Resources.ForumNoView); if (!permissionContext.UserCanPost) return GetReplyFailMessage(Resources.ForumNoPost); newPost.FullText = newPost.IsPlainText ? _textParsingService.ForumCodeToHtml(newPost.FullText) : _textParsingService.ClientHtmlToHtml(newPost.FullText); if (await IsNewPostDupeOrInTimeLimit(newPost.FullText, user)) return GetReplyFailMessage(string.Format(Resources.PostWait, _settingsManager.Current.MinimumSecondsBetweenPosts)); if (string.IsNullOrEmpty(newPost.FullText)) return GetReplyFailMessage(Resources.PostEmpty); if (newPost.ParentPostID != 0) { var parentPost = await _postRepository.Get(newPost.ParentPostID); if (parentPost == null || parentPost.TopicID != topic.TopicID) return GetReplyFailMessage("This reply attempt is being made to a post in another topic"); } newPost.Title = _textParsingService.Censor(newPost.Title); var postID = await _postRepository.Create(topic.TopicID, parentPostID, ip, isFirstInTopic, newPost.IncludeSignature, user.UserID, user.Name, newPost.Title, newPost.FullText, postTime, false, user.Name, null, false, 0); var post = new Post { PostID = postID, FullText = newPost.FullText, IP = ip, IsDeleted = false, IsEdited = false, IsFirstInTopic = isFirstInTopic, LastEditName = user.Name, LastEditTime = null, Name = user.Name, ParentPostID = parentPostID, PostTime = postTime, ShowSig = newPost.IncludeSignature, Title = newPost.Title, TopicID = topic.TopicID, UserID = user.UserID }; await _topicRepository.IncrementReplyCount(topic.TopicID); await _topicRepository.UpdateLastTimeAndUser(topic.TopicID, user.UserID, user.Name, postTime); await _forumRepository.UpdateLastTimeAndUser(topic.ForumID, postTime, user.Name); await _forumRepository.IncrementPostCount(topic.ForumID); await _searchIndexQueueRepository.Enqueue(new SearchIndexPayload { TenantID = _tenantService.GetTenant(), TopicID = topic.TopicID }); await _profileRepository.SetLastPostID(user.UserID, postID); var topicLink = topicLinkGenerator(topic); var tenantID = _tenantService.GetTenant(); await _subscribedTopicsService.NotifySubscribers(topic, user, tenantID); // {1} made a post in the topic: {3} var message = string.Format(Resources.NewReplyPublishMessage, userUrl, HtmlEncoder.Default.Encode(user.Name ?? string.Empty), postLinkGenerator(post), HtmlEncoder.Default.Encode(topic.Title ?? string.Empty)); var forumHasViewRestrictions = _forumRepository.GetForumViewRoles(topic.ForumID).Result.Count > 0; await _eventPublisher.ProcessEvent(message, user, EventDefinitionService.StaticEventIDs.NewPost, forumHasViewRestrictions); topic = await _topicRepository.Get(topic.TopicID); forum = await _forumRepository.Get(forum.ForumID); _broker.NotifyNewPosts(topic, post.PostID); _broker.NotifyNewPost(topic, post.PostID); _broker.NotifyForumUpdate(forum); _broker.NotifyTopicUpdate(topic, forum, topicLink); _topicViewCountService.SetViewedTopic(topic); if (newPost.CloseOnReply && user.IsInRole(PermanentRoles.Moderator)) { await _moderationLogService.LogTopic(user, ModerationType.TopicClose, topic, null); await _topicRepository.CloseTopic(topic.TopicID); } var redirectLink = redirectLinkGenerator(post); var profile = await _profileRepository.GetProfile(user.UserID); if (profile.IsAutoFollowOnReply) await _subscribedTopicsService.AddSubscribedTopic(user.UserID, topic.TopicID); await _postImageService.DeleteTempRecords(newPost.PostImageIDs, newPost.FullText); return new BasicServiceResponse { Data = post, Message = null, Redirect = redirectLink, IsSuccessful = true }; } public async Task> EditPost(int postID, PostEdit postEdit, User editingUser, Func redirectLinkGenerator) { var censoredNewTitle = _textParsingService.Censor(postEdit.Title); var post = await _postRepository.Get(postID); if (!editingUser.IsPostEditable(post)) return GetReplyFailMessage(Resources.Forbidden); var oldText = post.FullText; if (post.IsFirstInTopic && post.Title != censoredNewTitle) { if (string.IsNullOrEmpty(censoredNewTitle)) return GetReplyFailMessage(Resources.PostEmpty); var oldTitle = post.Title; post.Title = censoredNewTitle; var topic = await _topicRepository.Get(post.TopicID); var forum = await _forumRepository.Get(topic.ForumID); var urlName = censoredNewTitle.ToUniqueUrlName(await _topicRepository.GetUrlNamesThatStartWith(censoredNewTitle.ToUrlName())); await _topicRepository.UpdateTitleAndForum(topic.TopicID, forum.ForumID, censoredNewTitle, urlName); await _moderationLogService.LogTopic(editingUser, ModerationType.TopicRenamed, topic, forum, $"Old title: {oldTitle}"); } if (postEdit.IsPlainText) post.FullText = _textParsingService.ForumCodeToHtml(postEdit.FullText); else post.FullText = _textParsingService.ClientHtmlToHtml(postEdit.FullText); if (string.IsNullOrEmpty(postEdit.FullText)) return GetReplyFailMessage(Resources.PostEmpty); post.ShowSig = postEdit.ShowSig; post.LastEditTime = DateTime.UtcNow; post.LastEditName = editingUser.Name; post.IsEdited = true; await _postRepository.Update(post); await _moderationLogService.LogPost(editingUser, ModerationType.PostEdit, post, postEdit.Comment, oldText); await _searchIndexQueueRepository.Enqueue(new SearchIndexPayload { TenantID = _tenantService.GetTenant(), TopicID = post.TopicID, IsForRemoval = false }); var redirectLink = redirectLinkGenerator(post); await _postImageService.DeleteTempRecords(postEdit.PostImageIDs, postEdit.FullText); return new BasicServiceResponse { Data = post, IsSuccessful = true, Message = string.Empty, Redirect = redirectLink }; } private async Task IsNewPostDupeOrInTimeLimit(string parsedPost, User user) { var postID = await _profileRepository.GetLastPostID(user.UserID); if (postID == null) return false; var lastPost = await _postRepository.Get(postID.Value); if (lastPost == null) return false; var minimumSeconds = _settingsManager.Current.MinimumSecondsBetweenPosts; if (DateTime.UtcNow.Subtract(lastPost.PostTime).TotalSeconds < minimumSeconds) return true; if (parsedPost == lastPost.FullText) return true; return false; } } ================================================ FILE: src/PopForums/Services/PostService.cs ================================================ namespace PopForums.Services; public interface IPostService { Task, PagerContext>> GetPosts(Topic topic, bool includeDeleted, int pageIndex); Task, PagerContext>> GetPosts(Topic topic, int lastLoadedPostID, bool includeDeleted); Task> GetPosts(Topic topic, bool includeDeleted); Task Get(int postID); Task> GetTopicPageForPost(Post post, bool includeDeleted); Task GetPostCount(User user); Task GetPostForEdit(Post post, User user); Task Delete(Post post, User user); Task Undelete(Post post, User user); Task GetPostForQuote(Post post, User user, bool forcePlainText); Task> GetIPHistory(string ip, DateTime start, DateTime end); Task GetLastPostID(int topicID); Task GetVoters(Post post); Task GetVoteCount(Post post); Task> GetVotedPostIDs(User user, List posts); string GenerateParsedTextPreview(string text, bool isPlainText); Task> ToggleVoteReturnCountAndIsVoted(Post post, User user, string userUrl, string topicUrl, string topicTitle); } public class PostService : IPostService { public PostService(IPostRepository postRepository, IProfileRepository profileRepository, ISettingsManager settingsManager, ITopicService topicService, ITextParsingService textParsingService, IModerationLogService moderationLogService, IForumService forumService, IEventPublisher eventPublisher, IUserService userService, ISearchIndexQueueRepository searchIndexQueueRepository, ITenantService tenantService, INotificationAdapter notificationAdapter) { _postRepository = postRepository; _profileRepository = profileRepository; _settingsManager = settingsManager; _topicService = topicService; _textParsingService = textParsingService; _moderationLogService = moderationLogService; _forumService = forumService; _eventPublisher = eventPublisher; _userService = userService; _searchIndexQueueRepository = searchIndexQueueRepository; _tenantService = tenantService; _notificationAdapter = notificationAdapter; } private readonly IPostRepository _postRepository; private readonly IProfileRepository _profileRepository; private readonly ISettingsManager _settingsManager; private readonly ITopicService _topicService; private readonly ITextParsingService _textParsingService; private readonly IModerationLogService _moderationLogService; private readonly IForumService _forumService; private readonly IEventPublisher _eventPublisher; private readonly IUserService _userService; private readonly ISearchIndexQueueRepository _searchIndexQueueRepository; private readonly ITenantService _tenantService; private readonly INotificationAdapter _notificationAdapter; public async Task, PagerContext>> GetPosts(Topic topic, bool includeDeleted, int pageIndex) { var pageSize = _settingsManager.Current.PostsPerPage; var startRow = ((pageIndex - 1) * pageSize) + 1; var posts = await _postRepository.Get(topic.TopicID, includeDeleted, startRow, pageSize); int postCount; if (includeDeleted) postCount = await _postRepository.GetReplyCount(topic.TopicID, true); else postCount = topic.ReplyCount + 1; var totalPages = Convert.ToInt32(Math.Ceiling(Convert.ToDouble(postCount) / Convert.ToDouble(pageSize))); var pagerContext = new PagerContext { PageCount = totalPages, PageIndex = pageIndex, PageSize = pageSize }; return Tuple.Create(posts, pagerContext); } public async Task, PagerContext>> GetPosts(Topic topic, int lastLoadedPostID, bool includeDeleted) { var allPosts = await _postRepository.Get(topic.TopicID, includeDeleted); var lastIndex = allPosts.FindIndex(p => p.PostID == lastLoadedPostID); if (lastIndex < 0) throw new Exception($"PostID {lastLoadedPostID} is not a part of TopicID {topic.TopicID}."); var posts = allPosts.Skip(lastIndex + 1).ToList(); var pageSize = _settingsManager.Current.PostsPerPage; var totalPages = Convert.ToInt32(Math.Ceiling(Convert.ToDouble(allPosts.Count) / Convert.ToDouble(pageSize))); var pagerContext = new PagerContext { PageCount = totalPages, PageIndex = totalPages, PageSize = pageSize }; return Tuple.Create(posts, pagerContext); } public async Task> GetPosts(Topic topic, bool includeDeleted) { return await _postRepository.Get(topic.TopicID, includeDeleted); } public async Task Get(int postID) { return await _postRepository.Get(postID); } public async Task> GetTopicPageForPost(Post post, bool includeDeleted) { var topic = await _topicService.Get(post.TopicID); var ids = await _postRepository.GetPostIDsWithTimes(post.TopicID, includeDeleted); var postIDs = ids.Select(p => p.Key).ToList(); var index = postIDs.IndexOf(post.PostID); var pageSize = _settingsManager.Current.PostsPerPage; var page = Convert.ToInt32(Math.Floor((double)index/pageSize)) + 1; return Tuple.Create(page, topic); } public async Task GetPostCount(User user) { return await _postRepository.GetPostCount(user.UserID); } public async Task GetPostForEdit(Post post, User user) { if (post == null) throw new ArgumentNullException(nameof(post)); if (user == null) throw new ArgumentNullException(nameof(user)); var profile = await _profileRepository.GetProfile(user.UserID); var postEdit = new PostEdit(post) { IsPlainText = profile.IsPlainText, IsFirstInTopic = post.IsFirstInTopic }; if (profile.IsPlainText) { postEdit.FullText = _textParsingService.HtmlToForumCode(post.FullText); postEdit.IsPlainText = true; } else postEdit.FullText = _textParsingService.HtmlToClientHtml(post.FullText); return postEdit; } public async Task GetPostForQuote(Post post, User user, bool forcePlainText) { if (post == null) throw new ArgumentNullException(nameof(post)); if (post.IsDeleted) return "Post not found"; if (user == null) throw new ArgumentNullException(nameof(user)); var profile = await _profileRepository.GetProfile(user.UserID); string quote; if (profile.IsPlainText || forcePlainText) quote = $"[quote][i]{post.Name} said:[/i]\r\n{_textParsingService.HtmlToForumCode(post.FullText)}[/quote]\r\n\r\n"; else quote = $"
{post.Name} said:
{_textParsingService.HtmlToClientHtml(post.FullText)}

"; return quote; } public async Task Delete(Post post, User user) { if (user.UserID == post.UserID || user.IsInRole(PermanentRoles.Moderator)) { var topic = await _topicService.Get(post.TopicID); var forum = await _forumService.Get(topic.ForumID); if (post.IsFirstInTopic) await _topicService.DeleteTopic(topic, user); else { await _moderationLogService.LogPost(user, ModerationType.PostDelete, post, String.Empty, String.Empty); post.IsDeleted = true; post.LastEditTime = DateTime.UtcNow; post.LastEditName = user.Name; post.IsEdited = true; await _postRepository.Update(post); await _topicService.RecalculateReplyCount(topic); await _topicService.UpdateLast(topic); _forumService.UpdateCounts(forum); await _forumService.UpdateLast(forum); await _searchIndexQueueRepository.Enqueue(new SearchIndexPayload { TenantID = _tenantService.GetTenant(), TopicID = topic.TopicID }); } } else throw new InvalidOperationException("User must be Moderator or author to delete post."); } public async Task Undelete(Post post, User user) { if (user.IsInRole(PermanentRoles.Moderator)) { await _moderationLogService.LogPost(user, ModerationType.PostUndelete, post, String.Empty, String.Empty); post.IsDeleted = false; post.LastEditTime = DateTime.UtcNow; post.LastEditName = user.Name; post.IsEdited = true; await _postRepository.Update(post); var topic = await _topicService.Get(post.TopicID); await _topicService.RecalculateReplyCount(topic); await _topicService.UpdateLast(topic); var forum = await _forumService.Get(topic.ForumID); _forumService.UpdateCounts(forum); await _forumService.UpdateLast(forum); await _searchIndexQueueRepository.Enqueue(new SearchIndexPayload {TenantID = _tenantService.GetTenant(), TopicID = topic.TopicID}); } else throw new InvalidOperationException("User must be Moderator to undelete post."); } public async Task> GetIPHistory(string ip, DateTime start, DateTime end) { return await _postRepository.GetIPHistory(ip, start, end); } public async Task GetLastPostID(int topicID) { return await _postRepository.GetLastPostID(topicID); } public async Task GetVoters(Post post) { var results = await _postRepository.GetVotes(post.PostID); var filtered = results.Where(x => x.Value != null).ToDictionary(x => x.Key, x => x.Value); var container = new VotePostContainer { PostID = post.PostID, Votes = results.Count, Voters = filtered }; return container; } public async Task GetVoteCount(Post post) { return await _postRepository.GetVoteCount(post.PostID); } public async Task> GetVotedPostIDs(User user, List posts) { if (user == null) return new List(); var ids = posts.Select(x => x.PostID).ToList(); return await _postRepository.GetVotedPostIDs(user.UserID, ids); } public string GenerateParsedTextPreview(string text, bool isPlainText) { var result = isPlainText ? _textParsingService.ForumCodeToHtml(text) : _textParsingService.ClientHtmlToHtml(text); return result; } public async Task> ToggleVoteReturnCountAndIsVoted(Post post, User user, string userUrl, string topicUrl, string topicTitle) { if (user == null || post == null || post.UserID == user.UserID) return null; var voters = await _postRepository.GetVotes(post.PostID); var isVoted = false; if (voters.ContainsKey(user.UserID)) { await _postRepository.DeleteVote(post.PostID, user.UserID); } else { await _postRepository.VotePost(post.PostID, user.UserID); isVoted = true; } var votes = await _postRepository.CalculateVoteCount(post.PostID); await _postRepository.SetVoteCount(post.PostID, votes); var votedUpUser = await _userService.GetUser(post.UserID); if (votedUpUser != null) { if (isVoted) { // {1} voted for a post in the topic: {3} var message = string.Format(Resources.VoteUpPublishMessage, userUrl, HtmlEncoder.Default.Encode(user.Name ?? string.Empty), topicUrl, HtmlEncoder.Default.Encode(topicTitle ?? string.Empty)); await _eventPublisher.ProcessEvent(message, votedUpUser, EventDefinitionService.StaticEventIDs.PostVote, false); await _notificationAdapter.Vote(user.Name, topicTitle, post.PostID, votedUpUser.UserID); } else { var message = $"{HtmlEncoder.Default.Encode(user.Name ?? string.Empty)} -1: {HtmlEncoder.Default.Encode(topicTitle ?? string.Empty)}"; await _eventPublisher.ProcessEvent(message, votedUpUser, EventDefinitionService.StaticEventIDs.PostVoteUndo, false); } } return new Tuple(votes, isVoted); } } ================================================ FILE: src/PopForums/Services/PrivateMessageService.cs ================================================ namespace PopForums.Services; public interface IPrivateMessageService { Task Get(int pmID, int userID); Task> GetMostRecentPosts(int pmID, DateTime afterDateTime); Task> GetPosts(int pmID, DateTime beforeDateTime); Task, PagerContext>> GetPrivateMessages(User user, PrivateMessageBoxType boxType, int pageIndex); Task GetUnreadCount(int userID); Task Create(string fullText, User user, List toUsers); Task Reply(PrivateMessage pm, string fullText, User user); Task IsUserInPM(int userID, int pmID); Task MarkPMRead(int userID, int pmID); Task Archive(User user, PrivateMessage pm); Task Unarchive(User user, PrivateMessage pm); Task GetFirstUnreadPostID(int pmID, DateTime lastViewDate); Task> GetUsers(int pmID); } public class PrivateMessageService : IPrivateMessageService { public PrivateMessageService(IPrivateMessageRepository privateMessageRepo, ISettingsManager settingsManager, ITextParsingService textParsingService, IBroker broker) { _privateMessageRepository = privateMessageRepo; _settingsManager = settingsManager; _textParsingService = textParsingService; _broker = broker; } private readonly IPrivateMessageRepository _privateMessageRepository; private readonly ISettingsManager _settingsManager; private readonly ITextParsingService _textParsingService; private readonly IBroker _broker; private static int _postPageSize = 20; public async Task Get(int pmID, int userID) { return await _privateMessageRepository.Get(pmID, userID); } public async Task> GetMostRecentPosts(int pmID, DateTime afterDateTime) { return await _privateMessageRepository.GetPosts(pmID, afterDateTime); } public async Task> GetPosts(int pmID, DateTime beforeDateTime) { return await _privateMessageRepository.GetPosts(pmID, beforeDateTime, _postPageSize); } public async Task, PagerContext>> GetPrivateMessages(User user, PrivateMessageBoxType boxType, int pageIndex) { var total = await _privateMessageRepository.GetBoxCount(user.UserID, boxType); var pageSize = _settingsManager.Current.TopicsPerPage; var startRow = ((pageIndex - 1) * pageSize) + 1; var totalPages = Convert.ToInt32(Math.Ceiling(Convert.ToDouble(total) / Convert.ToDouble(pageSize))); var pagerContext = new PagerContext { PageCount = totalPages, PageIndex = pageIndex, PageSize = pageSize }; var messages = await _privateMessageRepository.GetPrivateMessages(user.UserID, boxType, startRow, pageSize); return Tuple.Create(messages, pagerContext); } public async Task GetUnreadCount(int userID) { return await _privateMessageRepository.GetUnreadCount(userID); } public async Task Create(string fullText, User user, List toUsers) { if (String.IsNullOrWhiteSpace(fullText)) throw new ArgumentNullException(nameof(fullText)); if (user == null) throw new ArgumentNullException(nameof(user)); if (toUsers == null || toUsers.Count == 0) throw new ArgumentException("toUsers must include at least one user.", nameof(toUsers)); var userIDs = toUsers.Select(x => x.UserID).ToList(); userIDs.Add(user.UserID); var existingPMID = await _privateMessageRepository.GetExistingFromIDs(userIDs); if (existingPMID != 0) { var existingPM = await _privateMessageRepository.Get(existingPMID, user.UserID); await Reply(existingPM, fullText, user); return existingPM; } var now = DateTime.UtcNow; var dynamicUserList = toUsers.Select(x => new {x.UserID, x.Name}).ToList(); dynamicUserList.Add(new {user.UserID, user.Name}); var serializedUsers = JsonSerializer.SerializeToElement(dynamicUserList, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); var pm = new PrivateMessage { Users = serializedUsers, LastPostTime = now }; pm.PMID = await _privateMessageRepository.CreatePrivateMessage(pm); var post = new PrivateMessagePost { FullText = _textParsingService.ForumCodeToHtml(fullText), Name = user.Name, PMID = pm.PMID, PostTime = now, UserID = user.UserID }; var lastReadDate = now.AddMinutes(-1); await _privateMessageRepository.AddPost(post); await _privateMessageRepository.AddUsers(pm.PMID, new List {user.UserID}, lastReadDate, false); await _privateMessageRepository.AddUsers(pm.PMID, toUsers.Select(u => u.UserID).ToList(), lastReadDate, false); foreach (var receiver in toUsers) { var receiverPMCount = await _privateMessageRepository.GetUnreadCount(receiver.UserID); _broker.NotifyPMCount(receiver.UserID, receiverPMCount); } return pm; } public async Task Reply(PrivateMessage pm, string fullText, User user) { if (pm == null || pm.PMID == 0) throw new ArgumentException("Can't reply to a PM that hasn't been persisted.", "pm"); if (fullText == null) throw new ArgumentNullException("fullText"); if (user == null) throw new ArgumentNullException("user"); if (await IsUserInPM(user.UserID, pm.PMID) == false) throw new Exception("Can't add a PM reply for a user not part of the PM."); var post = new PrivateMessagePost { FullText = _textParsingService.ForumCodeToHtml(fullText), Name = user.Name, PMID = pm.PMID, PostTime = DateTime.UtcNow, UserID = user.UserID }; await _privateMessageRepository.AddPost(post); var users = await _privateMessageRepository.GetUsers(pm.PMID); foreach (var u in users) await _privateMessageRepository.SetArchive(pm.PMID, u.UserID, false); var now = DateTime.UtcNow; await _privateMessageRepository.UpdateLastPostTime(pm.PMID, now); await _privateMessageRepository.SetLastViewTime(pm.PMID, user.UserID, now); _broker.SendPMMessage(post); foreach (var receiver in users) { var receiverPMCount = await _privateMessageRepository.GetUnreadCount(receiver.UserID); _broker.NotifyPMCount(receiver.UserID, receiverPMCount); } } public async Task IsUserInPM(int userID, int pmID) { var pmUsers = await _privateMessageRepository.GetUsers(pmID); return pmUsers.Count(p => p.UserID == userID) != 0; } public async Task MarkPMRead(int userID, int pmID) { await _privateMessageRepository.SetLastViewTime(pmID, userID, DateTime.UtcNow); var pmCount = await _privateMessageRepository.GetUnreadCount(userID); _broker.NotifyPMCount(userID, pmCount); } public async Task Archive(User user, PrivateMessage pm) { await _privateMessageRepository.SetArchive(pm.PMID, user.UserID, true); } public async Task Unarchive(User user, PrivateMessage pm) { await _privateMessageRepository.SetArchive(pm.PMID, user.UserID, false); } public async Task GetFirstUnreadPostID(int pmID, DateTime lastViewDate) { return await _privateMessageRepository.GetFirstUnreadPostID(pmID, lastViewDate); } public async Task> GetUsers(int pmID) { return await _privateMessageRepository.GetUsers(pmID); } } ================================================ FILE: src/PopForums/Services/ProfileService.cs ================================================ namespace PopForums.Services; public interface IProfileService { Task GetProfile(User user); Task Create(Profile profile); Task Update(Profile profile); Task GetProfileForEdit(User user, bool forcePlainText = false); Task EditUserProfile(User user, UserEditProfile userEditProfile); Task> GetSignatures(List posts); Task> GetAvatars(List posts); Task SetCurrentImageIDToNull(int userID); string GetUnsubscribeHash(User user); Task Unsubscribe(User user, string hash); Task UpdatePointTotal(User user); } public class ProfileService : IProfileService { private readonly IProfileRepository _profileRepository; private readonly ITextParsingService _textParsingService; private readonly IPointLedgerRepository _pointLedgerRepository; public ProfileService(IProfileRepository profileRepository, ITextParsingService textParsingService, IPointLedgerRepository pointLedgerRepository) { _profileRepository = profileRepository; _textParsingService = textParsingService; _pointLedgerRepository = pointLedgerRepository; } public async Task GetProfile(User user) { if (user == null) return null; return await _profileRepository.GetProfile(user.UserID); } public async Task GetProfileForEdit(User user, bool forcePlainText = false) { var profile = await _profileRepository.GetProfile(user.UserID); var userEditProfile = new Profile(); if (string.IsNullOrWhiteSpace(profile.Signature)) userEditProfile.Signature = string.Empty; else { if (profile.IsPlainText || forcePlainText) userEditProfile.Signature = _textParsingService.HtmlToForumCode(profile.Signature); else userEditProfile.Signature = _textParsingService.HtmlToClientHtml(profile.Signature); } userEditProfile.IsSubscribed = profile.IsSubscribed; userEditProfile.ShowDetails = profile.ShowDetails; userEditProfile.IsPlainText = profile.IsPlainText; userEditProfile.HideVanity = profile.HideVanity; userEditProfile.Location = profile.Location; userEditProfile.Dob = profile.Dob; userEditProfile.Web = profile.Web; userEditProfile.Instagram = profile.Instagram; userEditProfile.Facebook = profile.Facebook; userEditProfile.IsAutoFollowOnReply = profile.IsAutoFollowOnReply; return userEditProfile; } public async Task EditUserProfile(User user, UserEditProfile userEditProfile) { var profile = await _profileRepository.GetProfile(user.UserID); if (profile == null) throw new Exception($"No profile found for UserID {user.UserID}"); if (profile.IsPlainText) profile.Signature = _textParsingService.ForumCodeToHtml(userEditProfile.Signature); else profile.Signature = _textParsingService.ClientHtmlToHtml(userEditProfile.Signature); profile.IsSubscribed = userEditProfile.IsSubscribed; profile.ShowDetails = userEditProfile.ShowDetails; profile.IsPlainText = userEditProfile.IsPlainText; profile.HideVanity = userEditProfile.HideVanity; profile.Location = userEditProfile.Location; profile.Dob = userEditProfile.Dob; profile.Web = userEditProfile.Web; profile.Instagram = userEditProfile.Instagram; profile.Facebook = userEditProfile.Facebook; profile.IsAutoFollowOnReply = userEditProfile.IsAutoFollowOnReply; await _profileRepository.Update(profile); } public async Task Create(Profile profile) { if (profile.UserID == 0) throw new Exception("Can't create a profile not associated with a valid UserID"); await _profileRepository.Create(profile); } public async Task Update(Profile profile) { profile.Signature = profile.Signature.Trim(); if (await _profileRepository.Update(profile) == false) throw new Exception($"Profile with UserID {profile.UserID} does not exist."); } public async Task> GetSignatures(List posts) { var userIDs = posts.Where(p => p.ShowSig).Select(p => p.UserID).Distinct().ToList(); return await _profileRepository.GetSignatures(userIDs); } public async Task> GetAvatars(List posts) { var userIDs = posts.Select(p => p.UserID).Distinct().ToList(); return await _profileRepository.GetAvatars(userIDs); } public async Task SetCurrentImageIDToNull(int userID) { await _profileRepository.SetCurrentImageIDToNull(userID); } public string GetUnsubscribeHash(User user) { var source = user.Name + user.Email; return source.GetSHA256Hash().Replace("+", string.Empty).Replace("=", string.Empty); } public async Task Unsubscribe(User user, string hash) { var calculatedHash = GetUnsubscribeHash(user); if (calculatedHash != hash) return false; var profile = await GetProfile(user); profile.IsSubscribed = false; await Update(profile); return true; } public async Task UpdatePointTotal(User user) { var total = await _pointLedgerRepository.GetPointTotal(user.UserID); await _profileRepository.UpdatePoints(user.UserID, total); } } ================================================ FILE: src/PopForums/Services/QueuedEmailService.cs ================================================ namespace PopForums.Services; public interface IQueuedEmailService { Task CreateAndQueueEmail(QueuedEmailMessage queuedEmailMessage); } public class QueuedEmailService : IQueuedEmailService { private readonly IQueuedEmailMessageRepository _queuedEmailMessageRepository; private readonly IEmailQueueRepository _emailQueueRepository; private readonly ITenantService _tenantService; public QueuedEmailService(IQueuedEmailMessageRepository queuedEmailMessageRepository, IEmailQueueRepository emailQueueRepository, ITenantService tenantService) { _queuedEmailMessageRepository = queuedEmailMessageRepository; _emailQueueRepository = emailQueueRepository; _tenantService = tenantService; } public async Task CreateAndQueueEmail(QueuedEmailMessage queuedEmailMessage) { var id = await _queuedEmailMessageRepository.CreateMessage(queuedEmailMessage); var tenantID = _tenantService.GetTenant(); var payload = new EmailQueuePayload { MessageID = id, EmailQueuePayloadType = EmailQueuePayloadType.FullMessage, TenantID = tenantID }; await _emailQueueRepository.Enqueue(payload); } } ================================================ FILE: src/PopForums/Services/ReCaptchaService.cs ================================================ namespace PopForums.Services; public interface IReCaptchaService { Task VerifyToken(string token, string ip); } public class ReCaptchaService : IReCaptchaService { private readonly IConfig _config; private readonly ISecurityLogService _securityLogService; private const string VerifyUrl = "https://www.google.com/recaptcha/api/siteverify"; public ReCaptchaService(IConfig config, ISecurityLogService securityLogService) { _config = config; _securityLogService = securityLogService; } public async Task VerifyToken(string token, string ip) { var values = new Dictionary { {"secret", _config.ReCaptchaSecretKey}, {"response", token}, {"ip", ip} }; HttpResponseMessage httpResult; using (var client = new HttpClient()) { try { httpResult = await client.PostAsync(VerifyUrl, new FormUrlEncodedContent(values)); } catch (HttpRequestException) { return new ReCaptchaResponse {IsSuccess = false, ErrorCodes = new[] { "HttpRequestException" } }; } } var content = await httpResult.Content.ReadAsStringAsync(); var verifyResult = JsonSerializer.Deserialize(content); if (!verifyResult.IsSuccess) await _securityLogService.CreateLogEntry((User) null, null, ip, string.Empty, SecurityLogType.ReCaptchaFailed); return verifyResult; } } public class ReCaptchaResponse { [JsonPropertyName("success")] public bool IsSuccess { get; set; } [JsonPropertyName("challenge_ts")] public DateTime ChallengeTimeStamp { get; set; } [JsonPropertyName("hostname")] public string HostName { get; set; } [JsonPropertyName("error-codes")] public string[] ErrorCodes { get; set; } } ================================================ FILE: src/PopForums/Services/SearchIndexSubsystem.cs ================================================ namespace PopForums.Services; public interface ISearchIndexSubsystem { void DoIndex(int topicID, string tenantID, bool isForRemoval); void RemoveIndex(int topicID, string tenantID); } /// /// Implementation for in-database searching. Does not support multi-tenancy. /// public class SearchIndexSubsystem : ISearchIndexSubsystem { private readonly ISearchService _searchService; private readonly IPostService _postService; private readonly ITopicService _topicService; public SearchIndexSubsystem(ISearchService searchService, IPostService postService, ITopicService topicService) { _searchService = searchService; _postService = postService; _topicService = topicService; } public void DoIndex(int topicID, string tenantID, bool isForRemoval) { _searchService.DeleteAllIndexedWordsForTopic(topicID); if (isForRemoval) return; var topic = _topicService.Get(topicID).Result; if (topic == null) return; var junkList = _searchService.GetJunkWords().Result; var wordList = new List(); var alphaNum = SearchService.SearchWordPattern; var posts = _postService.GetPosts(topic, false).Result; foreach (var post in posts) { var firstPostMultiplier = 1; if (post.IsFirstInTopic) firstPostMultiplier = 2; var postWords = post.FullText.Split(new[] { " ", "\r\n" }, StringSplitOptions.RemoveEmptyEntries); if (postWords.Length > 0) { for (var x = 0; x < postWords.Length; x++) { foreach (Match match in alphaNum.Matches(postWords[x])) { TestForIndex(topic, match.Value, 1, firstPostMultiplier, true, wordList, junkList); } } } // index the name foreach (Match match in alphaNum.Matches(post.Name)) { TestForIndex(topic, match.Value, 2, firstPostMultiplier, false, wordList, junkList); } } // bonus for appearing in title foreach (Match match in alphaNum.Matches(topic.Title)) { TestForIndex(topic, match.Value, 20, 1, false, wordList, junkList); } foreach (var word in wordList) { _searchService.SaveSearchWord(word); } } public void RemoveIndex(int topicID, string tenantID) { } private void TestForIndex(Topic topic, string testWord, int increment, int multiplier, bool cap, List wordList, List junkList) { testWord = testWord.ToLower(); if (junkList.IndexOf(testWord) < 0) { var foundWord = wordList.Find(w => w.Word == testWord); if (foundWord != null) { foundWord.Rank += increment * multiplier; // cap the word frequency score if (cap && foundWord.Rank > 120) foundWord.Rank = 120; } else wordList.Add(new SearchWord { Rank = 1, TopicID = topic.TopicID, Word = testWord }); } } } ================================================ FILE: src/PopForums/Services/SearchIndexWorker.cs ================================================ namespace PopForums.Services; public interface ISearchIndexWorker { void Execute(); } public class SearchIndexWorker(IErrorLog errorLog, ISearchIndexSubsystem searchIndexSubsystem, ISearchService searchService) : ISearchIndexWorker { public async void Execute() { try { var payload = await searchService.GetNextTopicForIndexing(); if (payload == null) return; searchIndexSubsystem.DoIndex(payload.TopicID, payload.TenantID, payload.IsForRemoval); } catch (Exception exc) { errorLog.Log(exc, ErrorSeverity.Error); } } } ================================================ FILE: src/PopForums/Services/SearchService.cs ================================================ namespace PopForums.Services; public interface ISearchService { Task> GetJunkWords(); Task CreateJunkWord(string word); Task DeleteJunkWord(string word); Task>, PagerContext>> GetTopics(string searchTerm, SearchType searchType, User user, bool includeDeleted, int pageIndex); Task GetNextTopicForIndexing(); Task DeleteAllIndexedWordsForTopic(int topicID); Task SaveSearchWord(SearchWord searchWord); } public class SearchService : ISearchService { public SearchService(ISearchRepository searchRepository, ISettingsManager settingsManager, IForumService forumService, ISearchIndexQueueRepository searchIndexQueueRepository, IErrorLog errorLog) { _searchRepository = searchRepository; _settingsManager = settingsManager; _forumService = forumService; _searchIndexQueueRepository = searchIndexQueueRepository; _errorLog = errorLog; } private readonly ISearchRepository _searchRepository; private readonly ISettingsManager _settingsManager; private readonly IForumService _forumService; private readonly ISearchIndexQueueRepository _searchIndexQueueRepository; private readonly IErrorLog _errorLog; public static Regex SearchWordPattern = new Regex(@"[\w'\@\#\$\%\^\&\*]{2,}", RegexOptions.None); public async Task>, PagerContext>> GetTopics(string searchTerm, SearchType searchType, User user, bool includeDeleted, int pageIndex) { var nonViewableForumIDs = await _forumService.GetNonViewableForumIDs(user); var pageSize = _settingsManager.Current.TopicsPerPage; var startRow = ((pageIndex - 1) * pageSize) + 1; var topicCount = 0; Response> topics; PagerContext pagerContext; if (string.IsNullOrEmpty(searchTerm)) topics = new Response>(new List(), true); else { (topics, topicCount) = await _searchRepository.SearchTopics(searchTerm, nonViewableForumIDs, searchType, startRow, pageSize); } if (topics.IsValid) { var totalPages = Convert.ToInt32(Math.Ceiling(Convert.ToDouble(topicCount) / Convert.ToDouble(pageSize))); pagerContext = new PagerContext { PageCount = totalPages, PageIndex = pageIndex, PageSize = pageSize }; } else { topics = new Response>(new List(), false); pagerContext = new PagerContext {PageCount = 1, PageIndex = 1, PageSize = 1}; var exc = new Exception($"Search service error: {topics.Exception?.Message}"); _errorLog.Log(exc, ErrorSeverity.Warning, topics.DebugInfo); } return Tuple.Create(topics, pagerContext); } public async Task GetNextTopicForIndexing() { var payload = await _searchIndexQueueRepository.Dequeue(); return payload; } public async Task> GetJunkWords() { return await _searchRepository.GetJunkWords(); } public async Task CreateJunkWord(string word) { await _searchRepository.CreateJunkWord(word); } public async Task DeleteJunkWord(string word) { await _searchRepository.DeleteJunkWord(word); } public async Task DeleteAllIndexedWordsForTopic(int topicID) { await _searchRepository.DeleteAllIndexedWordsForTopic(topicID); } public async Task SaveSearchWord(SearchWord searchWord) { await _searchRepository.SaveSearchWord(searchWord.TopicID, searchWord.Word, searchWord.Rank); } } ================================================ FILE: src/PopForums/Services/SecurityLogService.cs ================================================ namespace PopForums.Services; public interface ISecurityLogService { Task> GetLogEntriesByUserID(int userID, DateTime startDate, DateTime endDate); Task> GetLogEntriesByUserName(string name, DateTime startDate, DateTime endDate); Task CreateLogEntry(User user, User targetUser, string ip, string message, SecurityLogType securityLogType); Task CreateLogEntry(int? userID, int? targetUserID, string ip, string message, SecurityLogType securityLogType); Task CreateLogEntry(int? userID, int? targetUserID, string ip, string message, SecurityLogType securityLogType, DateTime timeStamp); Task> GetIPHistory(string ip, DateTime start, DateTime end); } public class SecurityLogService : ISecurityLogService { public SecurityLogService(ISecurityLogRepository securityLogRepsoitory, IUserRepository userRepository) { _securityLogRepository = securityLogRepsoitory; _userRepository = userRepository; } private readonly ISecurityLogRepository _securityLogRepository; private readonly IUserRepository _userRepository; public async Task> GetLogEntriesByUserID(int userID, DateTime startDate, DateTime endDate) { return await _securityLogRepository.GetByUserID(userID, startDate, endDate); } public async Task> GetIPHistory(string ip, DateTime start, DateTime end) { return await _securityLogRepository.GetIPHistory(ip, start, end); } public async Task> GetLogEntriesByUserName(string name, DateTime startDate, DateTime endDate) { var user = await _userRepository.GetUserByName(name); if (user == null) return new List(); return await _securityLogRepository.GetByUserID(user.UserID, startDate, endDate); } public async Task CreateLogEntry(User user, User targetUser, string ip, string message, SecurityLogType securityLogType) { if (!string.IsNullOrEmpty(message) && message.Length > 255) message = message.Substring(0, 255); await CreateLogEntry(user?.UserID, targetUser?.UserID, ip, message, securityLogType); } public async Task CreateLogEntry(int? userID, int? targetUserID, string ip, string message, SecurityLogType securityLogType) { await CreateLogEntry(userID, targetUserID, ip, message, securityLogType, DateTime.UtcNow); } public async Task CreateLogEntry(int? userID, int? targetUserID, string ip, string message, SecurityLogType securityLogType, DateTime timeStamp) { if (ip == null) throw new ArgumentNullException("ip"); if (message == null) throw new ArgumentNullException("message"); var entry = new SecurityLogEntry { UserID = userID, TargetUserID = targetUserID, ActivityDate = timeStamp, IP = ip, Message = message, SecurityLogType = securityLogType }; await _securityLogRepository.Create(entry); } } ================================================ FILE: src/PopForums/Services/ServiceHeartbeatService.cs ================================================ namespace PopForums.Services; public interface IServiceHeartbeatService { Task RecordHeartbeat(string serviceName, string machineName); Task> GetAll(); Task ClearAll(); } public class ServiceHeartbeatService : IServiceHeartbeatService { private readonly IServiceHeartbeatRepository _serviceHeartbeatRepository; public ServiceHeartbeatService(IServiceHeartbeatRepository serviceHeartbeatRepository) { _serviceHeartbeatRepository = serviceHeartbeatRepository; } public async Task RecordHeartbeat(string serviceName, string machineName) { await _serviceHeartbeatRepository.RecordHeartbeat(serviceName, machineName, DateTime.UtcNow); } public async Task> GetAll() { return await _serviceHeartbeatRepository.GetAll(); } public async Task ClearAll() { await _serviceHeartbeatRepository.ClearAll(); } } ================================================ FILE: src/PopForums/Services/SetupService.cs ================================================ namespace PopForums.Services; public interface ISetupService { bool IsRuntimeConnectionAndSetupGood(); bool IsConnectionPossible(); bool IsDatabaseSetup(); Task> SetupDatabase(SetupVariables setupVariables); Exception SetupDatabaseWithoutSettingsOrUser(); } public class SetupService : ISetupService { public SetupService(ISetupRepository setupRepository, IUserService userService, ISettingsManager settingsManager, IProfileService profileService) { _setupRepository = setupRepository; _userService = userService; _settingsManager = settingsManager; _profileService = profileService; } private readonly ISetupRepository _setupRepository; private readonly IUserService _userService; private readonly ISettingsManager _settingsManager; private readonly IProfileService _profileService; private static bool _isConnectionSetupGood; private static readonly object _locker = new(); public bool IsRuntimeConnectionAndSetupGood() { if (_isConnectionSetupGood) return true; lock (_locker) { var canConnect = _setupRepository.IsConnectionPossible(); var isSetup = _setupRepository.IsDatabaseSetup(); _isConnectionSetupGood = canConnect && isSetup; } return _isConnectionSetupGood; } public bool IsConnectionPossible() { return _setupRepository.IsConnectionPossible(); } public bool IsDatabaseSetup() { return _setupRepository.IsDatabaseSetup(); } public Exception SetupDatabaseWithoutSettingsOrUser() { try { _setupRepository.SetupDatabase(); } catch (Exception exception) { return exception; } return null; } public async Task> SetupDatabase(SetupVariables setupVariables) { Exception exception = null; try { _setupRepository.SetupDatabase(); } catch (Exception exc) { exception = exc; return Tuple.Create(null, exception); } var settings = _settingsManager.Current; settings.ForumTitle = setupVariables.ForumTitle; settings.SmtpServer = setupVariables.SmtpServer; settings.SmtpPort = setupVariables.SmtpPort; settings.MailerAddress = setupVariables.MailerAddress; settings.UseSslSmtp = setupVariables.UseSslSmtp; settings.UseEsmtp = setupVariables.UseEsmtp; settings.SmtpUser = setupVariables.SmtpUser; settings.SmtpPassword = setupVariables.SmtpPassword; _settingsManager.SaveCurrent(); var user = await _userService.CreateUser(setupVariables.Name, setupVariables.Email, setupVariables.Password, true, ""); user.Roles = new List {PermanentRoles.Admin, PermanentRoles.Moderator}; var profile = new Profile { UserID = user.UserID, IsTos = true, IsSubscribed = true, ShowDetails = true, IsAutoFollowOnReply = true }; await _profileService.Create(profile); var edit = new UserEdit(user, profile); await _userService.EditUser(user, edit, false, false, null, null, "", user); //PopForumsActivation.StartServicesIfRunningInstance(); return Tuple.Create(user, exception); } } ================================================ FILE: src/PopForums/Services/SitemapService.cs ================================================ namespace PopForums.Services; public interface ISitemapService { Task GenerateIndex(Func pageLinkGenerator); Task GetSitemapPageCount(); Task GeneratePage(Func topicLinkGenerator, int page); } public class SitemapService : ISitemapService { private readonly ITopicRepository _topicRepository; private readonly IForumRepository _forumRepository; private const double _pageSize = 30000; public SitemapService(ITopicRepository topicRepository, IForumRepository forumRepository) { _topicRepository = topicRepository; _forumRepository = forumRepository; } public async Task GenerateIndex(Func pageLinkGenerator) { var pageCount = await GetSitemapPageCount(); var s = new StringBuilder(@" "); for (int p = 0; p < pageCount; p++) { s.Append("\t\r\n\t\t"); s.Append(pageLinkGenerator(p)); s.Append("\r\n\t\r\n"); } s.Append(""); var result = s.ToString(); return result; } public async Task GeneratePage(Func topicLinkGenerator, int page) { var nonViewableForumGraph = await _forumRepository.GetForumViewRestrictionRoleGraph(); // any forum with a role attached isn't viewable, shouldn't appear in sitemap var nonViewableForumIDs = nonViewableForumGraph.Where(x => x.Value.Count > 0).Select(x => x.Key).ToList(); var startRow = page == 0 ? 1 : (page * (int)_pageSize) + 1; var namesAndDates = await _topicRepository.GetUrlNames(false, nonViewableForumIDs, startRow, (int)_pageSize); var s = new StringBuilder(@" "); foreach (var item in namesAndDates) { s.Append("\t\r\n\t\t"); s.Append(topicLinkGenerator(item.Item1)); s.Append("\r\n\t\t"); s.Append(item.Item2.ToString("yyyy-MM-ddThh:mmzzz")); s.Append("\r\n\t\tdaily\r\n\t\r\n"); } s.Append(""); var result = s.ToString(); return result; } public async Task GetSitemapPageCount() { var nonViewableForumGraph = await _forumRepository.GetForumViewRestrictionRoleGraph(); // any forum with a role attached isn't viewable, shouldn't appear in sitemap var nonViewableForumIDs = nonViewableForumGraph.Where(x => x.Value.Count > 0).Select(x => x.Key).ToList(); var topicCount = await _topicRepository.GetTopicCount(false, nonViewableForumIDs); if (topicCount < _pageSize) return 1; var result = Math.Ceiling(topicCount / _pageSize); return Convert.ToInt32(result); } } ================================================ FILE: src/PopForums/Services/SubscribeNotificationWorker.cs ================================================ namespace PopForums.Services; public interface ISubscribeNotificationWorker { void Execute(); } public class SubscribeNotificationWorker(ISubscribeNotificationRepository subscribeNotificationRepository, ISubscribedTopicsService subscribedTopicsService, INotificationAdapter notificationAdapter, IErrorLog errorLog) : ISubscribeNotificationWorker { public async void Execute() { try { var payload = await subscribeNotificationRepository.Dequeue(); if (payload == null) return; var userIDs = await subscribedTopicsService.GetSubscribedUserIDs(payload.TopicID); var filteredUserIDs = userIDs.Where(x => x != payload.PostingUserID); foreach (var userID in filteredUserIDs) await notificationAdapter.Reply(payload.PostingUserName, payload.TopicTitle, payload.TopicID, userID, payload.TenantID); } catch (Exception exc) { errorLog.Log(exc, ErrorSeverity.Error); } } } ================================================ FILE: src/PopForums/Services/SubscribedTopicsService.cs ================================================ using PopForums.Models; namespace PopForums.Services; public interface ISubscribedTopicsService { Task AddSubscribedTopic(int userID, int topicID); Task RemoveSubscribedTopic(User user, Topic topic); Task TryRemoveSubscribedTopic(User user, Topic topic); Task NotifySubscribers(Topic topic, User postingUser, string tenantID); Task, PagerContext>> GetTopics(User user, int pageIndex); Task IsTopicSubscribed(int userID, int topicID); Task> GetSubscribedUserIDs(int topicID); } public class SubscribedTopicsService : ISubscribedTopicsService { public SubscribedTopicsService(ISubscribedTopicsRepository subscribedTopicsRepository, ISettingsManager settingsManager, INotificationAdapter notificationAdapter, ISubscribeNotificationRepository subscribeNotificationRepository) { _subscribedTopicsRepository = subscribedTopicsRepository; _settingsManager = settingsManager; _notificationAdapter = notificationAdapter; _subscribeNotificationRepository = subscribeNotificationRepository; } private readonly ISubscribedTopicsRepository _subscribedTopicsRepository; private readonly ISettingsManager _settingsManager; private readonly INotificationAdapter _notificationAdapter; private readonly ISubscribeNotificationRepository _subscribeNotificationRepository; public async Task AddSubscribedTopic(int userID, int topicID) { var isSubscribed = await _subscribedTopicsRepository.IsTopicSubscribed(userID, topicID); if (!isSubscribed) await _subscribedTopicsRepository.AddSubscribedTopic(userID, topicID); } public async Task RemoveSubscribedTopic(User user, Topic topic) { await _subscribedTopicsRepository.RemoveSubscribedTopic(user.UserID, topic.TopicID); } public async Task TryRemoveSubscribedTopic(User user, Topic topic) { if (user != null && topic != null) await RemoveSubscribedTopic(user, topic); } public async Task NotifySubscribers(Topic topic, User postingUser, string tenantID) { var payload = new SubscribeNotificationPayload { TopicID = topic.TopicID, TopicTitle = topic.Title, PostingUserID = postingUser.UserID, PostingUserName = postingUser.Name, TenantID = tenantID }; await _subscribeNotificationRepository.Enqueue(payload); } public async Task, PagerContext>> GetTopics(User user, int pageIndex) { var pageSize = _settingsManager.Current.TopicsPerPage; var startRow = ((pageIndex - 1) * pageSize) + 1; var topics = await _subscribedTopicsRepository.GetSubscribedTopics(user.UserID, startRow, pageSize); var topicCount = await _subscribedTopicsRepository.GetSubscribedTopicCount(user.UserID); var totalPages = Convert.ToInt32(Math.Ceiling(Convert.ToDouble(topicCount) / Convert.ToDouble(pageSize))); var pagerContext = new PagerContext { PageCount = totalPages, PageIndex = pageIndex, PageSize = pageSize }; return Tuple.Create(topics, pagerContext); } public async Task IsTopicSubscribed(int userID, int topicID) { return await _subscribedTopicsRepository.IsTopicSubscribed(userID, topicID); } public async Task> GetSubscribedUserIDs(int topicID) { return await _subscribedTopicsRepository.GetSubscribedUserIDs(topicID); } } ================================================ FILE: src/PopForums/Services/TenantService.cs ================================================ namespace PopForums.Services; public interface ITenantService { void SetTenant(string tenantID); string GetTenant(); } public class TenantService : ITenantService { public void SetTenant(string tenantID) { throw new NotImplementedException(); } public string GetTenant() { return string.Empty; } } ================================================ FILE: src/PopForums/Services/TextParsingService.cs ================================================ namespace PopForums.Services; public interface ITextParsingService { /// /// Converts forum code from the browser to HTML for storage. This method wraps and . /// /// Text to parse. /// Parsed text. string ForumCodeToHtml(string text); /// /// Converts client HTML from the browser to HTML for storage. This method wraps and . /// /// Text to parse. /// Parsed text. string ClientHtmlToHtml(string text); string HtmlToClientHtml(string text); string Censor(string text); /// /// Converts client HTML to forum code. Important: This method does NOT attempt to create valid HTML, as it assumes that the forum code /// will be cleaned. This method should generally not be called directly except for testing. /// /// Text to parse. /// Parsed text. string ClientHtmlToForumCode(string text); /// /// Cleans forum code by making sure tags are properly closed, escapes HTML, removes images if settings require it, removes extra line breaks /// and marks up URL's and e-mail addresses as links. This method should generally not be called directly except for testing. /// /// Text with forum code to clean. /// Cleaned forum code text. string CleanForumCode(string text); /// /// Converts forum code to HTML for storage. Important: This method does NOT attempt to create valid HTML, as it assumes that the forum code is /// already well-formed. This method should generally not be called directly except for testing. /// /// Text to parse. /// Parsed text. string CleanForumCodeToHtml(string text); string EscapeHtmlAndCensor(string text); string HtmlToForumCode(string text); /// /// Removes all forum code markup from the text. Useful for scrubbing text to be saved in search repositories. /// /// Text to parse. /// Parsed text. string RemoveForumCode(string text); } public class TextParsingService : ITextParsingService { public TextParsingService(ISettingsManager settingsManager) { _settingsManager = settingsManager; } private readonly ISettingsManager _settingsManager; public static string[] AllowedCloseableTags = { "b", "i", "code", "pre", "ul", "ol", "li", "url", "quote", "img" }; private static readonly Regex TagPattern = new Regex(@"\[[\w""\?=&/;\+%\*\:~,\!\.\-\$\|@#\(\)]+\]", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex TagID = new Regex(@"\[/?(\w+)\=*.*?\]", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex ProtocolPattern = new Regex(@"(?=/\w])(((news|(ht|f)tp(s?))\://)[\w\-\*]+(\.[\w\-/~\*]+)*/?)([\w\?=&/;\+%\*\:~,\.\-\$\|@#\(\)])*", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex WwwPattern = new Regex(@"(?=])(((http(s?))\://)[w*\.]*(youtu\.be|youtube\.com+))(?!.*/shorts/)(?!.*/post/)(?!/@)([\w\?=&/;\+%\*\:~,\.\-\$\|@#\(\)])*", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// /// Converts forum code from the browser to HTML for storage. This method wraps and , and censors the text. /// /// Text to parse. /// Parsed text. public string ForumCodeToHtml(string text) { text = Censor(text); text = CleanForumCode(text); text = CleanForumCodeToHtml(text); if (text == "

") text = String.Empty; return text; } public string EscapeHtmlAndCensor(string text) { text = Censor(text); return EscapeHtmlTags(text); } /// /// Converts client HTML from the browser to HTML for storage. This method wraps and , and censors the text. /// /// Text to parse. /// Parsed text. public string ClientHtmlToHtml(string text) { text = Censor(text); text = ClientHtmlToForumCode(text); text = CleanForumCode(text); return CleanForumCodeToHtml(text); } public string HtmlToClientHtml(string text) { text = Regex.Replace(text, @" *target=""[_\w]*""", String.Empty, RegexOptions.IgnoreCase); text = Regex.Replace(text, @"(", dictionary["v"], width, height)); } } else if (uri.Host.Contains("youtu.be")) { var v = uri.Segments[1]; text = text.Replace(item.Value, String.Format(@"", v, width, height)); } } return text; } } ================================================ FILE: src/PopForums/Services/TimeFormatStringService.cs ================================================ namespace PopForums.Services; public interface ITimeFormatStringService { TimeFormats GeTimeFormats(); string GetTimeFormatsAsJson(); } public class TimeFormatStringService : ITimeFormatStringService { public TimeFormats GeTimeFormats() { var formats = new TimeFormats { TodayTime = Resources.TodayTime, YesterdayTime = Resources.YesterdayTime, MinutesAgo = Resources.MinutesAgo, OneMinuteAgo = Resources.OneMinuteAgo, LessThanMinute = Resources.LessThanMinute }; return formats; } public string GetTimeFormatsAsJson() { var formats = GeTimeFormats(); var serialized = JsonSerializer.Serialize(formats, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); return serialized; } } ================================================ FILE: src/PopForums/Services/TopicService.cs ================================================ namespace PopForums.Services; public interface ITopicService { Task, PagerContext>> GetTopics(Forum forum, bool includeDeleted, int pageIndex); Task Get(string urlName); Task Get(int topicID); Task CloseTopic(Topic topic, User user); Task OpenTopic(Topic topic, User user); Task PinTopic(Topic topic, User user); Task UnpinTopic(Topic topic, User user); Task DeleteTopic(Topic topic, User user); Task UndeleteTopic(Topic topic, User user); Task UpdateTitleAndForum(Topic topic, Forum forum, string newTitle, User user); Task, PagerContext>> GetTopics(User viewingUser, User postUser, bool includeDeleted, int pageIndex); Task RecalculateReplyCount(Topic topic); Task> GetTopics(User viewingUser, Forum forum, bool includeDeleted); Task UpdateLast(Topic topic); Task TopicLastPostID(int topicID); Task HardDeleteTopic(Topic topic, User user); Task SetAnswer(User user, Topic topic, Post post, string userUrl, string topicUrl); Task QueueTopicForIndexing(int topicID); Task CloseAgedTopics(); } public class TopicService : ITopicService { public TopicService(ITopicRepository topicRepository, IPostRepository postRepository, ISettingsManager settingsManager, IModerationLogService moderationLogService, IForumService forumService, IEventPublisher eventPublisher, ISearchRepository searchRepository, IUserRepository userRepository, ISearchIndexQueueRepository searchIndexQueueRepository, ITenantService tenantService, INotificationAdapter notificationAdapter) { _topicRepository = topicRepository; _postRepository = postRepository; _settingsManager = settingsManager; _moderationLogService = moderationLogService; _forumService = forumService; _eventPublisher = eventPublisher; _searchRepository = searchRepository; _userRepository = userRepository; _searchIndexQueueRepository = searchIndexQueueRepository; _tenantService = tenantService; _notificationAdapter = notificationAdapter; } private readonly ITopicRepository _topicRepository; private readonly IPostRepository _postRepository; private readonly ISettingsManager _settingsManager; private readonly IModerationLogService _moderationLogService; private readonly IForumService _forumService; private readonly IEventPublisher _eventPublisher; private readonly ISearchRepository _searchRepository; private readonly IUserRepository _userRepository; private readonly ISearchIndexQueueRepository _searchIndexQueueRepository; private readonly ITenantService _tenantService; private readonly INotificationAdapter _notificationAdapter; public async Task, PagerContext>> GetTopics(Forum forum, bool includeDeleted, int pageIndex) { var pageSize = _settingsManager.Current.TopicsPerPage; var startRow = ((pageIndex - 1) * pageSize) + 1; var topics = await _topicRepository.Get(forum.ForumID, includeDeleted, startRow, pageSize); int topicCount; if (includeDeleted) topicCount = await _topicRepository.GetTopicCount(forum.ForumID, true); else topicCount = forum.TopicCount; var totalPages = Convert.ToInt32(Math.Ceiling(Convert.ToDouble(topicCount) / Convert.ToDouble(pageSize))); var pagerContext = new PagerContext { PageCount = totalPages, PageIndex = pageIndex, PageSize = pageSize }; return Tuple.Create(topics, pagerContext); } public async Task> GetTopics(User viewingUser, Forum forum, bool includeDeleted) { var nonViewableForumIDs = await _forumService.GetNonViewableForumIDs(viewingUser); var topics = await _topicRepository.Get(forum.ForumID, includeDeleted, nonViewableForumIDs); return topics; } public async Task, PagerContext>> GetTopics(User viewingUser, User postUser, bool includeDeleted, int pageIndex) { var nonViewableForumIDs = await _forumService.GetNonViewableForumIDs(viewingUser); var pageSize = _settingsManager.Current.TopicsPerPage; var startRow = ((pageIndex - 1) * pageSize) + 1; var topics = await _topicRepository.GetTopicsByUser(postUser.UserID, includeDeleted, nonViewableForumIDs, startRow, pageSize); var topicCount = await _topicRepository.GetTopicCountByUser(postUser.UserID, includeDeleted, nonViewableForumIDs); var totalPages = Convert.ToInt32(Math.Ceiling(Convert.ToDouble(topicCount) / Convert.ToDouble(pageSize))); var pagerContext = new PagerContext { PageCount = totalPages, PageIndex = pageIndex, PageSize = pageSize }; return Tuple.Create(topics, pagerContext); } public async Task Get(string urlName) { return await _topicRepository.Get(urlName); } public async Task Get(int topicID) { return await _topicRepository.Get(topicID); } public async Task CloseTopic(Topic topic, User user) { if (user.IsInRole(PermanentRoles.Moderator)) { await _moderationLogService.LogTopic(user, ModerationType.TopicClose, topic, null); await _topicRepository.CloseTopic(topic.TopicID); } else throw new InvalidOperationException("User must be Moderator to close topic."); } public async Task OpenTopic(Topic topic, User user) { if (user.IsInRole(PermanentRoles.Moderator)) { await _moderationLogService.LogTopic(user, ModerationType.TopicOpen, topic, null); await _topicRepository.OpenTopic(topic.TopicID); } else throw new InvalidOperationException("User must be Moderator to open topic."); } public async Task PinTopic(Topic topic, User user) { if (user.IsInRole(PermanentRoles.Moderator)) { await _moderationLogService.LogTopic(user, ModerationType.TopicPin, topic, null); await _topicRepository.PinTopic(topic.TopicID); } else throw new InvalidOperationException("User must be Moderator to pin topic."); } public async Task UnpinTopic(Topic topic, User user) { if (user.IsInRole(PermanentRoles.Moderator)) { await _moderationLogService.LogTopic(user, ModerationType.TopicUnpin, topic, null); await _topicRepository.UnpinTopic(topic.TopicID); } else throw new InvalidOperationException("User must be Moderator to unpin topic."); } public async Task DeleteTopic(Topic topic, User user) { if (user.IsInRole(PermanentRoles.Moderator) || user.UserID == topic.StartedByUserID) { await _moderationLogService.LogTopic(user, ModerationType.TopicDelete, topic, null); await _topicRepository.DeleteTopic(topic.TopicID); await _searchIndexQueueRepository.Enqueue(new SearchIndexPayload { TenantID = _tenantService.GetTenant(), TopicID = topic.TopicID, IsForRemoval = true }); await RecalculateReplyCount(topic); var forum = await _forumService.Get(topic.ForumID); _forumService.UpdateCounts(forum); await _forumService.UpdateLast(forum); } else throw new InvalidOperationException("User must be Moderator or topic starter to delete topic."); } public async Task HardDeleteTopic(Topic topic, User user) { if (user.IsInRole(PermanentRoles.Admin)) { await _moderationLogService.LogTopic(user, ModerationType.TopicDeletePermanently, topic, null); await _searchIndexQueueRepository.Enqueue(new SearchIndexPayload { TenantID = _tenantService.GetTenant(), TopicID = topic.TopicID, IsForRemoval = true }); await _topicRepository.HardDeleteTopic(topic.TopicID); var forum = await _forumService.Get(topic.ForumID); _forumService.UpdateCounts(forum); await _forumService.UpdateLast(forum); } else throw new InvalidOperationException("User must be Admin to hard delete topic."); } public async Task UndeleteTopic(Topic topic, User user) { if (user.IsInRole(PermanentRoles.Moderator)) { await _moderationLogService.LogTopic(user, ModerationType.TopicUndelete, topic, null); await _topicRepository.UndeleteTopic(topic.TopicID); await _searchIndexQueueRepository.Enqueue(new SearchIndexPayload { TenantID = _tenantService.GetTenant(), TopicID = topic.TopicID, IsForRemoval = false }); await RecalculateReplyCount(topic); var forum = await _forumService.Get(topic.ForumID); _forumService.UpdateCounts(forum); await _forumService.UpdateLast(forum); } else throw new InvalidOperationException("User must be Moderator to undelete topic."); } public async Task UpdateTitleAndForum(Topic topic, Forum forum, string newTitle, User user) { if (user.IsInRole(PermanentRoles.Moderator)) { var oldTopic = await _topicRepository.Get(topic.TopicID); if (oldTopic.ForumID != forum.ForumID) await _moderationLogService.LogTopic(user, ModerationType.TopicMoved, topic, forum, $"Moved from {oldTopic.ForumID} to {forum.ForumID}"); if (oldTopic.Title != newTitle) await _moderationLogService.LogTopic(user, ModerationType.TopicRenamed, topic, forum, $"Renamed from \"{oldTopic.Title}\" to \"{newTitle}\""); var urlName = newTitle.ToUniqueUrlName(await _topicRepository.GetUrlNamesThatStartWith(newTitle.ToUrlName())); topic.UrlName = urlName; await _topicRepository.UpdateTitleAndForum(topic.TopicID, forum.ForumID, newTitle, urlName); await _searchIndexQueueRepository.Enqueue(new SearchIndexPayload { TenantID = _tenantService.GetTenant(), TopicID = topic.TopicID, IsForRemoval = false }); _forumService.UpdateCounts(forum); await _forumService.UpdateLast(forum); var oldForum = await _forumService.Get(oldTopic.ForumID); _forumService.UpdateCounts(oldForum); await _forumService.UpdateLast(oldForum); } else throw new InvalidOperationException("User must be Moderator to update topic title or move topic."); } public async Task RecalculateReplyCount(Topic topic) { var replyCount = await _postRepository.GetReplyCount(topic.TopicID, false); await _topicRepository.UpdateReplyCount(topic.TopicID, replyCount); } public async Task UpdateLast(Topic topic) { var post = await _postRepository.GetLastInTopic(topic.TopicID); await _topicRepository.UpdateLastTimeAndUser(topic.TopicID, post.UserID, post.Name, post.PostTime); } public async Task TopicLastPostID(int topicID) { var post = await _postRepository.GetLastInTopic(topicID); if (post == null) return 0; return post.PostID; } public async Task SetAnswer(User user, Topic topic, Post post, string userUrl, string topicUrl) { if (user.UserID != topic.StartedByUserID) throw new SecurityException("Only the user that started a topic may set its answer."); if (post == null || post.TopicID != topic.TopicID) throw new InvalidOperationException("You can't use a post as an answer unless it's a child of the topic."); var answerUser = await _userRepository.GetUser(post.UserID); if (answerUser != null // answer user is still valid && !topic.AnswerPostID.HasValue && // an answer wasn't already chosen topic.StartedByUserID != post.UserID) // the answer isn't coming from the question asker { // {1} chose an answer for the question: {3} var message = String.Format(Resources.QuestionAnswered, userUrl, HtmlEncoder.Default.Encode(user.Name ?? string.Empty), topicUrl, HtmlEncoder.Default.Encode(topic.Title ?? string.Empty)); await _eventPublisher.ProcessEvent(message, answerUser, EventDefinitionService.StaticEventIDs.QuestionAnswered, false); } await _topicRepository.UpdateAnswerPostID(topic.TopicID, post.PostID); await _notificationAdapter.QuestionAnswer(user.Name, topic.Title, post.PostID, post.UserID); } public async Task QueueTopicForIndexing(int topicID) { await _searchIndexQueueRepository.Enqueue(new SearchIndexPayload { TenantID = _tenantService.GetTenant(), TopicID = topicID, IsForRemoval = false }); } public async Task CloseAgedTopics() { if (!_settingsManager.Current.IsClosingAgedTopics) return; var ageCutoff = DateTime.UtcNow.AddDays(-_settingsManager.Current.CloseAgedTopicsDays); var list = await _topicRepository.CloseTopicsOlderThan(ageCutoff); foreach (var id in list) await _moderationLogService.LogTopic(ModerationType.TopicCloseAuto, id); } } ================================================ FILE: src/PopForums/Services/TopicViewLogService.cs ================================================ namespace PopForums.Services; public interface ITopicViewLogService { Task LogView(int? userID, int topicID); } public class TopicViewLogService : ITopicViewLogService { private readonly IConfig _config; private readonly ITopicViewLogRepository _topicViewLogRepository; public TopicViewLogService(IConfig config, ITopicViewLogRepository topicViewLogRepository) { _config = config; _topicViewLogRepository = topicViewLogRepository; } public async Task LogView(int? userID, int topicID) { if (!_config.LogTopicViews) return; var timeStamp = DateTime.UtcNow; await _topicViewLogRepository.Log(userID, topicID, timeStamp); } } ================================================ FILE: src/PopForums/Services/UserEmailReconciler.cs ================================================ namespace PopForums.Services; public interface IUserEmailReconciler { Task GetUniqueEmail(string email, string externalID); } /// /// Checks for existing email addresses from an external identity provider. If a match is found, it mangles the address /// to use an "example.com" address, which is not real per IETF RFC 2606. /// public class UserEmailReconciler : IUserEmailReconciler { private readonly IUserRepository _userRepository; public UserEmailReconciler(IUserRepository userRepository) { _userRepository = userRepository; } public async Task GetUniqueEmail(string email, string externalID) { var match = await _userRepository.GetUserByEmail(email); if (match is null) return email; var uniqueEmail = $"{email.Replace("@","-at-")}@{externalID}.example.com"; return uniqueEmail; } } ================================================ FILE: src/PopForums/Services/UserNameReconciler.cs ================================================ namespace PopForums.Services; public interface IUserNameReconciler { Task GetUniqueNameForUser(string name); } /// /// Used to make sure that incoming names from an external identity provider are unique. In other words, if there's /// a "John Smith," the next one becomes "John Smith-2." /// public class UserNameReconciler : IUserNameReconciler { private readonly IUserRepository _userRepository; public UserNameReconciler(IUserRepository userRepository) { _userRepository = userRepository; } public async Task GetUniqueNameForUser(string name) { var existingMatches = await _userRepository.GetUserNamesThatStartWith(name); var uniqueName = name.ToUniqueName(existingMatches.ToList()); return uniqueName; } } ================================================ FILE: src/PopForums/Services/UserService.cs ================================================ namespace PopForums.Services; public interface IUserService { Task SetPassword(User targetUser, string password, string ip, User user); Task> CheckPassword(string email, string password); Task GetUser(int userID); Task GetUserByName(string name); Task GetUserByEmail(string email); Task GetUserByAuhtorizationKey(Guid authorizationKey); Task IsNameInUse(string name); Task IsEmailInUse(string email); Task CreateUserWithProfile(SignupData signupData, string ip); Task CreateUser(string name, string email, string password, bool isApproved, string ip); Task DeleteUser(User targetUser, User user, string ip, bool ban); Task UpdateLastActivityDate(User user); Task ChangeEmail(User targetUser, string newEmail, User user, string ip); Task ChangeEmail(User targetUser, string newEmail, User user, string ip, bool isUserApproved); Task ChangeName(User targetUser, string newName, User user, string ip); Task UpdateIsApproved(User targetUser, bool isApproved, User user, string ip); Task UpdateAuthorizationKey(User user, Guid key); Task Logout(User user, string ip); Task> Login(string email, string password, string ip); Task Login(User user, string ip); Task> GetAllRoles(); Task CreateRole(string role, User user, string ip); Task DeleteRole(string role, User user, string ip); Task VerifyAuthorizationCode(Guid key, string ip); Task> SearchByEmail(string email); Task> SearchByName(string name); Task> SearchByRole(string role); Task EditUser(User targetUser, UserEdit userEdit, bool removeAvatar, bool removePhoto, byte[] avatarFile, byte[] photoFile, string ip, User user); Task EditUserProfileImages(User user, bool removeAvatar, bool removePhoto, byte[] avatarFile, byte[] photoFile); Task GetUserEdit(User user); bool IsPasswordValid(string password, out string errorMessage); Task IsEmailInUseByDifferentUser(User user, string email); Task> GetUsersOnline(); Task IsIPBanned(string ip); Task IsEmailBanned(string email); Task GeneratePasswordResetEmail(User user, string resetLink); Task ResetPassword(User user, string newPassword, string ip); Task> GetUsersFromIDs(IList ids); Task GetTotalUsers(); Task> GetSubscribedUsers(); Dictionary GetUsersByPointTotals(int top); Task> GetRecentUsers(); Task UpdateTokenExpiration(User user, DateTime? tokenExpiration); Task UpdateRefreshToken(User user, string refreshToken); Task GetRefreshToken(User user); } public class UserService : IUserService { private readonly IUserRepository _userRepository; private readonly IRoleRepository _roleRepository; private readonly IProfileRepository _profileRepository; private readonly ISettingsManager _settingsManager; private readonly IUserAvatarRepository _userAvatarRepository; private readonly IUserImageRepository _userImageRepository; private readonly ISecurityLogService _securityLogService; private readonly ITextParsingService _textParsingService; private readonly IBanRepository _banRepository; private readonly IForgotPasswordMailer _forgotPasswordMailer; private readonly IImageService _imageService; private readonly IConfig _config; // TODO: Dependencies on imageservice public UserService(IUserRepository userRepository, IRoleRepository roleRepository, IProfileRepository profileRepository, ISettingsManager settingsManager, IUserAvatarRepository userAvatarRepository, IUserImageRepository userImageRepository, ISecurityLogService securityLogService, ITextParsingService textParsingService, IBanRepository banRepository, IForgotPasswordMailer forgotPasswordMailer, IImageService imageService, IConfig config) { _userRepository = userRepository; _roleRepository = roleRepository; _profileRepository = profileRepository; _settingsManager = settingsManager; _userAvatarRepository = userAvatarRepository; _userImageRepository = userImageRepository; _securityLogService = securityLogService; _textParsingService = textParsingService; _banRepository = banRepository; _forgotPasswordMailer = forgotPasswordMailer; _imageService = imageService; _config = config; } public async Task SetPassword(User targetUser, string password, string ip, User user) { var salt = Guid.NewGuid(); var hashedPassword = password.GetSHA256Hash(salt); await _userRepository.SetHashedPassword(targetUser, hashedPassword, salt); await _securityLogService.CreateLogEntry(user, targetUser, ip, string.Empty, SecurityLogType.PasswordChange); } public async Task> CheckPassword(string email, string password) { string hashedPassword; var (storedHash, salt) = await _userRepository.GetHashedPasswordByEmail(email); if (salt.HasValue) hashedPassword = password.GetSHA256Hash(salt.Value); else hashedPassword = password.GetSHA256Hash(); if (storedHash == hashedPassword) return Tuple.Create(true, salt); // legacy check var oldResult = await CheckOldHashedPassword(email, password, salt, storedHash); return Tuple.Create(oldResult, salt); } /// /// This method is used to maintain compatibility with really old and crusty instances of POP Forums /// that used MD5 to hash passwords. It upgrades those passwords if they match. /// private async Task CheckOldHashedPassword(string email, string password, Guid? salt, string storedHash) { string hashedPassword; if (salt.HasValue) hashedPassword = password.GetMD5Hash(salt.Value); else hashedPassword = password.GetMD5Hash(); if (storedHash == hashedPassword) { // upgrade the password hash var user = await _userRepository.GetUserByEmail(email); await SetPassword(user, password, string.Empty, null); return true; } return false; } public async Task GetUser(int userID) { var user = await _userRepository.GetUser(userID); await PopulateRoles(user); return user; } public async Task GetUserByName(string name) { if (string.IsNullOrWhiteSpace(name)) return null; name = name.ToLower(); var user = await _userRepository.GetUserByName(name); if (user == null) return null; await PopulateRoles(user); return user; } public async Task GetUserByAuhtorizationKey(Guid authorizationKey) { var user = await _userRepository.GetUserByAuthorizationKey(authorizationKey); await PopulateRoles(user); return user; } public async Task GetUserByEmail(string email) { if (string.IsNullOrWhiteSpace(email)) return null; email = email.ToLower(); var user = await _userRepository.GetUserByEmail(email); await PopulateRoles(user); return user; } public async Task> GetUsersFromIDs(IList ids) { return await _userRepository.GetUsersFromIDs(ids); } private async Task PopulateRoles(User user) { if (user != null) user.Roles = await _roleRepository.GetUserRoles(user.UserID); } public async Task IsNameInUse(string name) { return await GetUserByName(name) != null; } public async Task IsEmailInUse(string email) { return await GetUserByEmail(email) != null; } public async Task IsEmailInUseByDifferentUser(User user, string email) { var otherUser = await GetUserByEmail(email); if (otherUser == null) return false; return otherUser.Email != user.Email; } public async Task IsIPBanned(string ip) { return await _banRepository.IPIsBanned(ip); } public async Task IsEmailBanned(string email) { return await _banRepository.EmailIsBanned(email); } public async Task CreateUserWithProfile(SignupData signupData, string ip) { var isApproved = _config.IsOAuthOnly || _settingsManager.Current.IsNewUserApproved; var user = await CreateUser(signupData.Name, signupData.Email, signupData.Password, isApproved, ip); var profile = new Profile { UserID = user.UserID, IsSubscribed = signupData.IsSubscribed, IsTos = signupData.IsTos, IsAutoFollowOnReply = signupData.IsAutoFollowOnReply }; await _profileRepository.Create(profile); return user; } public async Task CreateUser(string name, string email, string password, bool isApproved, string ip) { name = _textParsingService.Censor(name); if (!email.IsEmailAddress()) throw new Exception("E-mail address invalid."); if (string.IsNullOrEmpty(name)) throw new Exception("Name must not be empty or null."); if (await IsNameInUse(name)) throw new Exception($"The name \"{name}\" is already in use."); if (await IsEmailInUse(email)) throw new Exception($"The e-mail \"{email}\" is already in use."); if (await IsIPBanned(ip)) throw new Exception($"The IP {ip} is banned."); if (await IsEmailBanned(email)) throw new Exception($"The e-mail {email} is banned."); var creationDate = DateTime.UtcNow; var authorizationKey = Guid.NewGuid(); var salt = Guid.NewGuid(); var hashedPassword = password.GetSHA256Hash(salt); var user = await _userRepository.CreateUser(name, email, creationDate, isApproved, hashedPassword, authorizationKey, salt); await _securityLogService.CreateLogEntry(null, user, ip, string.Empty, SecurityLogType.UserCreated); return user; } public async Task DeleteUser(User targetUser, User user, string ip, bool ban) { if (ban) await _banRepository.BanEmail(targetUser.Email); await _userRepository.DeleteUser(targetUser); await _securityLogService.CreateLogEntry(user, targetUser, ip, $"Name: {targetUser.Name}, E-mail: {targetUser.Email}", SecurityLogType.UserDeleted); } public async Task UpdateLastActivityDate(User user) { await _userRepository.UpdateLastActivityDate(user, DateTime.UtcNow); } public async Task ChangeEmail(User targetUser, string newEmail, User user, string ip) { await ChangeEmail(targetUser, newEmail, user, ip, _settingsManager.Current.IsNewUserApproved); } public async Task ChangeEmail(User targetUser, string newEmail, User user, string ip, bool isUserApproved) { if (!newEmail.IsEmailAddress()) throw new Exception("E-mail address invalid."); if (await IsEmailInUse(newEmail)) throw new Exception($"The e-mail \"{newEmail}\" is already in use."); var oldEmail = targetUser.Email; await _userRepository.ChangeEmail(targetUser, newEmail); targetUser.Email = newEmail; await _userRepository.UpdateIsApproved(targetUser, isUserApproved); await _securityLogService.CreateLogEntry(user, targetUser, ip, $"Old: {oldEmail}, New: {newEmail}", SecurityLogType.EmailChange); } public async Task ChangeName(User targetUser, string newName, User user, string ip) { if (string.IsNullOrEmpty(newName)) throw new Exception("Name must not be empty or null."); if (await IsNameInUse(newName)) throw new Exception($"The name \"{newName}\" is already in use."); var oldName = targetUser.Name; await _userRepository.ChangeName(targetUser, newName); targetUser.Name = newName; await _securityLogService.CreateLogEntry(user, targetUser, ip, $"Old: {oldName}, New: {newName}", SecurityLogType.NameChange); } public async Task UpdateIsApproved(User targetUser, bool isApproved, User user, string ip) { if (targetUser == null) throw new ArgumentNullException("targetUser"); await _userRepository.UpdateIsApproved(targetUser, isApproved); var logType = isApproved ? SecurityLogType.IsApproved : SecurityLogType.IsNotApproved; await _securityLogService.CreateLogEntry(user, targetUser, ip, String.Empty, logType); } public async Task UpdateAuthorizationKey(User user, Guid key) { if (user == null) throw new ArgumentNullException("user"); await _userRepository.UpdateAuthorizationKey(user, key); } public async Task Logout(User user, string ip) { // used only for logging; controller performs actual logout await _securityLogService.CreateLogEntry(null, user, ip, String.Empty, SecurityLogType.Logout); } public async Task> Login(string email, string password, string ip) { User user; var (result, salt) = await CheckPassword(email, password); if (result) { user = await GetUserByEmail(email); await _userRepository.UpdateLastLoginDate(user, DateTime.UtcNow); await _securityLogService.CreateLogEntry(null, user, ip, String.Empty, SecurityLogType.Login); if (!salt.HasValue) await SetPassword(user, password, ip, user); } else { user = null; await _securityLogService.CreateLogEntry((User)null, null, ip, "E-mail attempted: " + email, SecurityLogType.FailedLogin); } return Tuple.Create(result, user); } public async Task Login(User user, string ip) { await _userRepository.UpdateLastLoginDate(user, DateTime.UtcNow); await _securityLogService.CreateLogEntry(null, user, ip, String.Empty, SecurityLogType.Login); } public async Task> GetAllRoles() { return await _roleRepository.GetAllRoles(); } public async Task CreateRole(string role, User user, string ip) { await _roleRepository.CreateRole(role); await _securityLogService.CreateLogEntry(user, null, ip, "Role: " + role, SecurityLogType.RoleCreated); } public async Task DeleteRole(string role, User user, string ip) { if (role.ToLower() == PermanentRoles.Admin.ToLower() || role.ToLower() == PermanentRoles.Moderator.ToLower()) throw new InvalidOperationException("Can't delete Admin or Moderator roles."); await _roleRepository.DeleteRole(role); await _securityLogService.CreateLogEntry(user, null, ip, "Role: " + role, SecurityLogType.RoleDeleted); } public async Task VerifyAuthorizationCode(Guid key, string ip) { var targetUser = await _userRepository.GetUserByAuthorizationKey(key); if (targetUser == null) return null; var newKey = Guid.NewGuid(); await UpdateAuthorizationKey(targetUser, newKey); await UpdateIsApproved(targetUser, true, null, ip); targetUser.AuthorizationKey = newKey; return targetUser; } public async Task> SearchByEmail(string email) { return await _userRepository.SearchByEmail(email); } public async Task> SearchByName(string name) { return await _userRepository.SearchByName(name); } public async Task> SearchByRole(string role) { return await _userRepository.SearchByRole(role); } public async Task GetUserEdit(User user) { if (user == null) throw new ArgumentNullException("user"); var profile = await _profileRepository.GetProfile(user.UserID); return new UserEdit(user, profile); } /// /// Used only from setup service and admin user edits. /// /// /// /// /// /// /// /// /// /// public async Task EditUser(User targetUser, UserEdit userEdit, bool removeAvatar, bool removePhoto, byte[] avatarFile, byte[] photoFile, string ip, User user) { if (!string.IsNullOrWhiteSpace(userEdit.NewEmail)) await ChangeEmail(targetUser, userEdit.NewEmail, user, ip, userEdit.IsApproved); if (!string.IsNullOrWhiteSpace(userEdit.NewPassword)) await SetPassword(targetUser, userEdit.NewPassword, ip, user); if (targetUser.IsApproved != userEdit.IsApproved) await UpdateIsApproved(targetUser, userEdit.IsApproved, user, ip); var profile = await _profileRepository.GetProfile(targetUser.UserID); profile.IsSubscribed = userEdit.IsSubscribed; profile.ShowDetails = userEdit.ShowDetails; profile.IsPlainText = userEdit.IsPlainText; profile.HideVanity = userEdit.HideVanity; profile.Signature = _textParsingService.ForumCodeToHtml(userEdit.Signature); profile.Location = userEdit.Location; profile.Dob = userEdit.Dob; profile.Web = userEdit.Web; profile.Instagram = userEdit.Instagram; profile.Facebook = userEdit.Facebook; profile.IsAutoFollowOnReply = userEdit.IsAutoFollowOnReply; if (removeAvatar) profile.AvatarID = null; if (removePhoto) profile.ImageID = null; await _profileRepository.Update(profile); if (!_config.IsOAuthOnly) { var newRoles = userEdit.Roles ?? new string[0]; await _roleRepository.ReplaceUserRoles(targetUser.UserID, newRoles); foreach (var role in targetUser.Roles) if (!newRoles.Contains(role)) await _securityLogService.CreateLogEntry(user, targetUser, ip, role, SecurityLogType.UserRemovedFromRole); foreach (var role in newRoles) if (!targetUser.Roles.Contains(role)) await _securityLogService.CreateLogEntry(user, targetUser, ip, role, SecurityLogType.UserAddedToRole); } if (avatarFile != null && avatarFile.Length > 0) { var avatarID = await _userAvatarRepository.SaveNewAvatar(targetUser.UserID, avatarFile, DateTime.UtcNow); profile.AvatarID = avatarID; await _profileRepository.Update(profile); } if (photoFile != null && photoFile.Length > 0) { var imageID = await _userImageRepository.SaveNewImage(targetUser.UserID, 0, true, photoFile, DateTime.UtcNow); profile.ImageID = imageID; await _profileRepository.Update(profile); } } public async Task EditUserProfileImages(User user, bool removeAvatar, bool removePhoto, byte[] avatarFile, byte[] photoFile) { var profile = await _profileRepository.GetProfile(user.UserID); if (removeAvatar) { await _userAvatarRepository.DeleteAvatarsByUserID(user.UserID); profile.AvatarID = null; } if (removePhoto) { await _userImageRepository.DeleteImagesByUserID(user.UserID); profile.ImageID = null; } await _profileRepository.Update(profile); if (avatarFile != null && avatarFile.Length > 0) { await _userAvatarRepository.DeleteAvatarsByUserID(user.UserID); var bytes = _imageService.ConstrainResize(avatarFile, _settingsManager.Current.UserAvatarMaxWidth, _settingsManager.Current.UserAvatarMaxHeight, 70, true); var avatarID = await _userAvatarRepository.SaveNewAvatar(user.UserID, bytes, DateTime.UtcNow); profile.AvatarID = avatarID; await _profileRepository.Update(profile); } if (photoFile != null && photoFile.Length > 0) { await _userImageRepository.DeleteImagesByUserID(user.UserID); var bytes = _imageService.ConstrainResize(photoFile, _settingsManager.Current.UserImageMaxWidth, _settingsManager.Current.UserImageMaxHeight, 70, false); var imageID = await _userImageRepository.SaveNewImage(user.UserID, 0, _settingsManager.Current.IsNewUserImageApproved, bytes, DateTime.UtcNow); profile.ImageID = imageID; await _profileRepository.Update(profile); } } public bool IsPasswordValid(string password, out string errorMessage) { if (String.IsNullOrEmpty(password) || password.Length < 6) { errorMessage = "Password must be at least six characters"; return false; } errorMessage = null; return true; } public async Task> GetUsersOnline() { return await _userRepository.GetUsersOnline(); } public async Task GetTotalUsers() { return await _userRepository.GetTotalUsers(); } public async Task GeneratePasswordResetEmail(User user, string resetLink) { if (user == null) throw new ArgumentNullException("user"); var newAuth = Guid.NewGuid(); await UpdateAuthorizationKey(user, newAuth); user.AuthorizationKey = newAuth; var link = resetLink + "/" + newAuth; await _forgotPasswordMailer.ComposeAndQueue(user, link); } public async Task ResetPassword(User user, string newPassword, string ip) { await SetPassword(user, newPassword, ip, null); await UpdateAuthorizationKey(user, Guid.NewGuid()); await Login(user, ip); } public async Task> GetSubscribedUsers() { return await _userRepository.GetSubscribedUsers(); } public Dictionary GetUsersByPointTotals(int top) { return _userRepository.GetUsersByPointTotals(top); } public async Task> GetRecentUsers() { var userResults = await _userRepository.GetRecentUsers(); if (!string.IsNullOrEmpty(_config.IpLookupUrlFormat)) { foreach (var item in userResults) { var url = string.Format(_config.IpLookupUrlFormat, item.IP); item.IP = $"{item.IP}"; } } return userResults; } public async Task UpdateTokenExpiration(User user, DateTime? tokenExpiration) { await _userRepository.UpdateTokenExpiration(user, tokenExpiration); } public async Task UpdateRefreshToken(User user, string refreshToken) { await _userRepository.UpdateRefreshToken(user, refreshToken); } public async Task GetRefreshToken(User user) { return await _userRepository.GetRefreshToken(user); } } ================================================ FILE: src/PopForums/Services/UserSessionService.cs ================================================ namespace PopForums.Services; public interface IUserSessionService { Task ProcessUserRequest(User user, int? sessionID, string ip, Action deleteSession, Action createSession); Task CleanUpExpiredSessions(); Task GetTotalSessionCount(); } public class UserSessionService : IUserSessionService { public UserSessionService(ISettingsManager settingsManager, IUserRepository userRepository, IUserSessionRepository userSessionRepository, ISecurityLogService securityLogService) { _settingsManager = settingsManager; _userRepository = userRepository; _userSessionRepository = userSessionRepository; _securityLogService = securityLogService; } private readonly ISettingsManager _settingsManager; private readonly IUserRepository _userRepository; private readonly IUserSessionRepository _userSessionRepository; private readonly ISecurityLogService _securityLogService; public const string _sessionIDCookieName = "pf_sessionID"; public async Task ProcessUserRequest(User user, int? sessionID, string ip, Action deleteSession, Action createSession) { int? userID = null; if (user != null) userID = user.UserID; if (sessionID == null) { sessionID = await StartNewSession(userID, ip, createSession); if (user != null) await _userRepository.UpdateLastActivityDate(user, DateTime.UtcNow); } else { if (user != null) await _userRepository.UpdateLastActivityDate(user, DateTime.UtcNow); var updateSuccess = await _userSessionRepository.UpdateSession(sessionID.Value, DateTime.UtcNow); if (!updateSuccess) sessionID = await StartNewSession(userID, ip, createSession); else { var isAnon = await _userSessionRepository.IsSessionAnonymous(sessionID.Value); if (userID.HasValue && isAnon || !userID.HasValue && !isAnon) { deleteSession(); await EndAndDeleteSession(new ExpiredUserSession { UserID = null, SessionID = sessionID.Value, LastTime = DateTime.UtcNow }); sessionID = await StartNewSession(userID, ip, createSession); } } } return sessionID.Value; } private async Task StartNewSession(int? userID, string ip, Action createSession) { if (userID.HasValue) { var oldUserSession = await _userSessionRepository.GetSessionIDByUserID(userID.Value); if (oldUserSession != null) await EndAndDeleteSession(oldUserSession); } var random = new Random(); var sessionID = random.Next(int.MinValue, int.MaxValue); await _securityLogService.CreateLogEntry(null, userID, ip, sessionID.ToString(), SecurityLogType.UserSessionStart); await _userSessionRepository.CreateSession(sessionID, userID, DateTime.UtcNow); createSession(sessionID); return sessionID; } private async Task EndAndDeleteSession(ExpiredUserSession oldUserSession) { await _securityLogService.CreateLogEntry(null, oldUserSession.UserID, string.Empty, oldUserSession.SessionID.ToString(), SecurityLogType.UserSessionEnd, oldUserSession.LastTime); await _userSessionRepository.DeleteSessions(oldUserSession.UserID, oldUserSession.SessionID); } public async Task CleanUpExpiredSessions() { var cutOff = DateTime.UtcNow.Subtract(new TimeSpan(0, _settingsManager.Current.SessionLength, 0)); var expiredSessions = await _userSessionRepository.GetAndDeleteExpiredSessions(cutOff); foreach (var session in expiredSessions) await _securityLogService.CreateLogEntry(null, session.UserID, string.Empty, session.SessionID.ToString(), SecurityLogType.UserSessionEnd, session.LastTime); } public async Task GetTotalSessionCount() { return await _userSessionRepository.GetTotalSessionCount(); } } ================================================ FILE: src/PopForums/Services/UserSessionWorker.cs ================================================ namespace PopForums.Services; public interface IUserSessionWorker { void Execute(); } public class UserSessionWorker(IUserSessionService sessionService, IErrorLog errorLog) : IUserSessionWorker { public async void Execute() { try { await sessionService.CleanUpExpiredSessions(); } catch (Exception exc) { errorLog.Log(exc, ErrorSeverity.Error); } } } ================================================ FILE: src/PopForums.AzureKit/Logging/ErrorLogRepository.cs ================================================ using PopForums.Configuration; using PopForums.Models; using PopForums.Repositories; using PopForums.Services; using System.Collections.Generic; using System.Threading.Tasks; using System; using Azure.Data.Tables; namespace PopForums.AzureKit.Logging; public class ErrorLogRepository : IErrorLogRepository { private readonly ITenantService _tenantService; private readonly IConfig _config; private const string ErrorTableName = "pferrorlog"; private TableClient _tableClient; public ErrorLogRepository(ITenantService tenantService, IConfig config) { _tenantService = tenantService; _config = config; } public async Task Create(DateTime timeStamp, string message, string stackTrace, string data, ErrorSeverity severity) { var tableClient = await GetTableClient(); var errorLog = new ErrorLogEntry { ErrorID = 0, TimeStamp = timeStamp, Message = message, StackTrace = stackTrace, Data = data, Severity = severity }; var tenantID = _tenantService.GetTenant(); var rowKey = DateTime.UtcNow.ToString("s") + Random.Shared.Next(100000,999999); var entry = new TableEntity($"{DateTime.UtcNow.Year}-{DateTime.UtcNow.DayOfYear.ToString("D3")}", rowKey) { { "TenantID", tenantID }, { "Message", errorLog.Message }, { "StackTrace", errorLog.StackTrace }, { "Data", errorLog.Data }, { "Severity", errorLog.Severity.ToString() } }; await tableClient.AddEntityAsync(entry); return errorLog; } private async Task GetTableClient() { if (_tableClient != null) return _tableClient; var tableClient = new TableClient(_config.StorageConnectionString, ErrorTableName); await tableClient.CreateIfNotExistsAsync(); _tableClient = tableClient; return tableClient; } public Task GetErrorCount() { return Task.FromResult(1); } public Task> GetErrors(int startRow, int pageSize) { var entry = new ErrorLogEntry {Message = "Check table storage for errors."}; var list = new List {entry}; return Task.FromResult(list); } public Task DeleteError(int errorID) { throw new NotImplementedException(); } public async Task DeleteAllErrors() { var tableClient = new TableClient(_config.StorageConnectionString, ErrorTableName); await tableClient.DeleteAsync(); } } ================================================ FILE: src/PopForums.AzureKit/PopForums.AzureKit.csproj ================================================ PopForums AzureKit Class Library 22.0.0 Jeff Putz net10.0 PopForums.AzureKit PopForums.AzureKit true https://github.com/POPWorldMedia/POPForums https://github.com/POPWorldMedia/POPForums 2025, POP World Media, LLC MIT ================================================ FILE: src/PopForums.AzureKit/PostImage/PostImageRepository.cs ================================================ using System; using System.Threading.Tasks; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; using PopForums.Configuration; using PopForums.Models; using PopForums.Repositories; using PopForums.Services; namespace PopForums.AzureKit.PostImage; public class PostImageRepository : IPostImageRepository { private readonly IConfig _config; private readonly ITenantService _tenantService; private static string _containerName = "postimage"; public PostImageRepository(IConfig config, ITenantService tenantService) { _config = config; _tenantService = tenantService; } public async Task Persist(byte[] bytes, string contentType) { var container = new BlobContainerClient(_config.StorageConnectionString, _containerName); await container.CreateIfNotExistsAsync(PublicAccessType.Blob); var tenant = _tenantService.GetTenant(); if (string.IsNullOrWhiteSpace(tenant)) tenant = "_"; var id = Guid.NewGuid().ToString(); var path = $"{tenant}/{id}"; var blob = container.GetBlobClient(path); var binary = new BinaryData(bytes); await blob.UploadAsync(binary); await blob.SetHttpHeadersAsync(new BlobHttpHeaders {ContentType = contentType, CacheControl = "private"}); var url = _config.BaseImageBlobUrl + "/" + _containerName + "/" + path; var payload = new PostImagePersistPayload { Url = url, ID = id }; return payload; } public async Task DeletePostImageData(string id, string tenantID) { var container = new BlobContainerClient(_config.StorageConnectionString, _containerName); await container.CreateIfNotExistsAsync(PublicAccessType.Blob); var tenant = _tenantService.GetTenant(); if (string.IsNullOrWhiteSpace(tenant)) tenant = "_"; var path = $"{tenant}/{id}"; await container.DeleteBlobAsync(path, DeleteSnapshotsOption.IncludeSnapshots); } // The next three methods are not used when fetching images from Azure storage. The // default SQL implementation does use these. public Task GetWithoutData(string id) { throw new NotImplementedException(); } public Task Get(string id) { throw new NotImplementedException(); } public Task GetImageStream(string id) { throw new NotImplementedException(); } } ================================================ FILE: src/PopForums.AzureKit/Queue/AwardCalculationQueueRepository.cs ================================================ using System; using System.Collections.Generic; using System.Text.Json; using System.Threading.Tasks; using Azure.Core; using Azure.Storage.Queues; using PopForums.Configuration; using PopForums.Models; using PopForums.Repositories; namespace PopForums.AzureKit.Queue; public class AwardCalculationQueueRepository : IAwardCalculationQueueRepository { private readonly IConfig _config; private readonly IErrorLog _errorLog; public const string QueueName = "pfawardcalcqueue"; public AwardCalculationQueueRepository(IConfig config, IErrorLog errorLog) { _config = config; _errorLog = errorLog; } public async Task Enqueue(AwardCalculationPayload payload) { var serializedPayload = JsonSerializer.Serialize(payload); try { var client = await GetQueueClient(); await client.SendMessageAsync(serializedPayload); } catch (Exception exc) { _errorLog.Log(exc, ErrorSeverity.Warning, $"AwardCalc enqueue failed on payload: {serializedPayload}"); } } #pragma warning disable 1998 public async Task> Dequeue() { throw new NotImplementedException($"{nameof(Dequeue)} should never be called because it's automatically bound to an Azure function."); } #pragma warning restore 1998 private async Task GetQueueClient() { var options = new QueueClientOptions { MessageEncoding = QueueMessageEncoding.Base64, Retry = { NetworkTimeout = TimeSpan.FromSeconds(2), MaxRetries = 1, Mode = RetryMode.Fixed, Delay = TimeSpan.Zero, MaxDelay = TimeSpan.Zero } }; var client = new QueueClient(_config.QueueConnectionString, QueueName, options); await client.CreateIfNotExistsAsync(); return client; } } ================================================ FILE: src/PopForums.AzureKit/Queue/EmailQueueRepository.cs ================================================ using System.Text.Json; using System.Threading.Tasks; using Azure.Storage.Queues; using PopForums.Configuration; using PopForums.Email; using PopForums.Repositories; namespace PopForums.AzureKit.Queue; public class EmailQueueRepository : IEmailQueueRepository { private readonly IConfig _config; public const string QueueName = "pfemailqueue"; public EmailQueueRepository(IConfig config) { _config = config; } public async Task Enqueue(EmailQueuePayload payload) { var serializedPayload = JsonSerializer.Serialize(payload); var client = await GetQueueClient(); await client.SendMessageAsync(serializedPayload); } public Task Dequeue() { throw new System.NotImplementedException($"{nameof(Dequeue)} should never be called because it's automatically bound to an Azure function."); } private async Task GetQueueClient() { var client = new QueueClient(_config.QueueConnectionString, QueueName, new QueueClientOptions { MessageEncoding = QueueMessageEncoding.Base64 }); await client.CreateIfNotExistsAsync(); return client; } } ================================================ FILE: src/PopForums.AzureKit/Queue/SearchIndexQueueRepository.cs ================================================ using System; using System.Text.Json; using System.Threading.Tasks; using Azure.Core; using Azure.Storage.Queues; using PopForums.Configuration; using PopForums.Models; using PopForums.Repositories; namespace PopForums.AzureKit.Queue; public class SearchIndexQueueRepository : ISearchIndexQueueRepository { private readonly IConfig _config; private readonly IErrorLog _errorLog; public const string QueueName = "pfsearchindexqueue"; public SearchIndexQueueRepository(IConfig config, IErrorLog errorLog) { _config = config; _errorLog = errorLog; } public async Task Enqueue(SearchIndexPayload payload) { var serializedPayload = JsonSerializer.Serialize(payload); try { var client = await GetQueueClient(); await client.SendMessageAsync(serializedPayload); } catch (Exception exc) { _errorLog.Log(exc, ErrorSeverity.Warning, $"SearchIndex enqueue failed on payload: {serializedPayload}"); } } #pragma warning disable 1998 public async Task Dequeue() { throw new NotImplementedException($"{nameof(Dequeue)} should never be called because it's automatically bound to an Azure function."); } #pragma warning restore 1998 private async Task GetQueueClient() { var options = new QueueClientOptions { MessageEncoding = QueueMessageEncoding.Base64, Retry = { NetworkTimeout = TimeSpan.FromSeconds(2), MaxRetries = 1, Mode = RetryMode.Fixed, Delay = TimeSpan.Zero, MaxDelay = TimeSpan.Zero } }; var client = new QueueClient(_config.QueueConnectionString, QueueName, options); await client.CreateIfNotExistsAsync(); return client; } } ================================================ FILE: src/PopForums.AzureKit/Queue/SubscribeNotificationRepository.cs ================================================ using System; using System.Text.Json; using System.Threading.Tasks; using Azure.Core; using Azure.Storage.Queues; using PopForums.Configuration; using PopForums.Models; using PopForums.Repositories; namespace PopForums.AzureKit.Queue; public class SubscribeNotificationRepository : ISubscribeNotificationRepository { private readonly IConfig _config; private readonly IErrorLog _errorLog; public const string QueueName = "pfsubnotifyqueue"; public SubscribeNotificationRepository(IConfig config, IErrorLog errorLog) { _config = config; _errorLog = errorLog; } public async Task Enqueue(SubscribeNotificationPayload payload) { var serializedPayload = JsonSerializer.Serialize(payload); try { var client = await GetQueueClient(); await client.SendMessageAsync(serializedPayload); } catch (Exception exc) { _errorLog.Log(exc, ErrorSeverity.Warning, $"SubNotify enqueue failed on payload: {serializedPayload}"); } } #pragma warning disable 1998 public Task Dequeue() { throw new NotImplementedException($"{nameof(Dequeue)} should never be called because it's automatically bound to an Azure function."); } #pragma warning restore 1998 private async Task GetQueueClient() { var options = new QueueClientOptions { MessageEncoding = QueueMessageEncoding.Base64, Retry = { NetworkTimeout = TimeSpan.FromSeconds(2), MaxRetries = 1, Mode = RetryMode.Fixed, Delay = TimeSpan.Zero, MaxDelay = TimeSpan.Zero } }; var client = new QueueClient(_config.QueueConnectionString, QueueName, options); await client.CreateIfNotExistsAsync(); return client; } } ================================================ FILE: src/PopForums.AzureKit/Redis/CacheHelper.cs ================================================ using System; using System.Collections.Generic; using System.Text.Json; using Microsoft.Extensions.Caching.Memory; using PopForums.Configuration; using PopForums.Services; using StackExchange.Redis; namespace PopForums.AzureKit.Redis; public class CacheHelper : ICacheHelper { private readonly IErrorLog _errorLog; private readonly ITenantService _tenantService; private readonly IConfig _config; private readonly ICacheTelemetry _cacheTelemetry; private readonly RedisChannel _removeChannel = RedisChannel.Literal("pf.cache.remove"); private static ConnectionMultiplexer _cacheConnection; private static ConnectionMultiplexer _messageConnection; private static IMemoryCache _cache; private static readonly object SyncRoot = new object(); private static class CacheTelemetryNames { public const string SetMemory = "SetMemory"; public const string SetRedis = "SetRedis"; public const string GetMemoryHit = "GetMemoryHit"; public const string GetMemoryMiss = "GetMemoryMiss"; public const string GetRedisHit = "GetRedisHit"; public const string GetRedisMiss = "GetRedisMiss"; public const string GetRedisError = "GetRedisError"; public const string SetRedisError = "SetRedisError"; } public CacheHelper(IErrorLog errorLog, ITenantService tenantService, IConfig config, ICacheTelemetry cacheTelemetry) { _errorLog = errorLog; _tenantService = tenantService; _config = config; _cacheTelemetry = cacheTelemetry; // Redis cache if (_cacheConnection == null) { lock (SyncRoot) { if (_cacheConnection == null) _cacheConnection = ConnectionMultiplexer.Connect(_config.CacheConnectionString); } } // Local cache if (_cache == null) SetupLocalCache(); // Redis messaging to invalidate local cache entries if (_messageConnection == null) { lock (SyncRoot) { _messageConnection = ConnectionMultiplexer.Connect(_config.CacheConnectionString); var db = _messageConnection.GetSubscriber(); db.Subscribe(_removeChannel, (_, value) => { if (_cache == null) return; var valueString = value.ToString(); _cache.Remove(valueString); OnRemoveCacheKey?.Invoke(valueString); }); } } } public event Action OnRemoveCacheKey; public string GetEffectiveCacheKey(string key) { var tenantID = _tenantService.GetTenant(); return $"{tenantID}:{key}"; } private static void SetupLocalCache() { lock (SyncRoot) { var options = new MemoryCacheOptions(); _cache = new MemoryCache(options); } } public void SetCacheObject(string key, object value) { SetCacheObject(key, value, _config.CacheSeconds); } public void SetCacheObject(string key, object value, double seconds) { key = GetEffectiveCacheKey(key); var timeSpan = TimeSpan.FromSeconds(seconds); var options = new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = timeSpan }; _cacheTelemetry.Start(); _cache.Set(key, value, options); _cacheTelemetry.End(CacheTelemetryNames.SetMemory, key); _cacheTelemetry.Start(); try { var db = _cacheConnection.GetDatabase(); var serialized = JsonSerializer.Serialize(value); db.StringSet(key, serialized, timeSpan, flags: CommandFlags.FireAndForget); _cacheTelemetry.End(CacheTelemetryNames.SetRedis, key); } catch (Exception exc) { _cacheTelemetry.End(CacheTelemetryNames.SetRedisError, key); _errorLog.Log(exc, ErrorSeverity.Information); } } public void SetLongTermCacheObject(string key, object value) { key = GetEffectiveCacheKey(key); var options = new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(60) }; _cacheTelemetry.Start(); _cache.Set(key, value, options); _cacheTelemetry.End(CacheTelemetryNames.SetMemory, key); _cacheTelemetry.Start(); try { var db = _cacheConnection.GetDatabase(); var serialized = JsonSerializer.Serialize(value); db.StringSet(key, serialized, flags: CommandFlags.FireAndForget); _cacheTelemetry.End(CacheTelemetryNames.SetRedis, key); } catch (Exception exc) { _cacheTelemetry.End(CacheTelemetryNames.SetRedisError, key); _errorLog.Log(exc, ErrorSeverity.Information); } } public void SetPagedListCacheObject(string rootKey, int page, List value) { rootKey = GetEffectiveCacheKey(rootKey); _cache.TryGetValue(rootKey, out Dictionary> rootPages); if (rootPages == null) rootPages = new Dictionary>(); else if (rootPages.ContainsKey(page)) rootPages.Remove(page); rootPages.Add(page, value); var options = new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(_config.CacheSeconds) }; _cacheTelemetry.Start(); _cache.Set(rootKey, rootPages, options); _cacheTelemetry.End(CacheTelemetryNames.SetMemory, rootKey); } public void RemoveCacheObject(string key) { key = GetEffectiveCacheKey(key); _cache.Remove(key); try { var db = _cacheConnection.GetDatabase(); db.KeyDelete(key); var bus = _messageConnection.GetDatabase(); bus.Publish(_removeChannel, key, CommandFlags.FireAndForget); } catch (Exception exc) { _errorLog.Log(exc, ErrorSeverity.Information); } } public T GetCacheObject(string key) { key = GetEffectiveCacheKey(key); _cacheTelemetry.Start(); var cacheObject = _cache.Get(key); if (cacheObject != null) { _cacheTelemetry.End(CacheTelemetryNames.GetMemoryHit, key); return (T)cacheObject; } _cacheTelemetry.End(CacheTelemetryNames.GetMemoryMiss, key); try { var db = _cacheConnection.GetDatabase(); _cacheTelemetry.Start(); var result = db.StringGet(key); if (string.IsNullOrEmpty(result)) { _cacheTelemetry.End(CacheTelemetryNames.GetRedisMiss, key); return default; } _cacheTelemetry.End(CacheTelemetryNames.GetRedisHit, key); var deserialized = JsonSerializer.Deserialize(result.ToString()); var timeSpan = TimeSpan.FromSeconds(_config.CacheSeconds); var options = new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = timeSpan }; _cacheTelemetry.Start(); _cache.Set(key, deserialized, options); _cacheTelemetry.End(CacheTelemetryNames.SetMemory, key); return deserialized; } catch (Exception exc) { _cacheTelemetry.End(CacheTelemetryNames.GetRedisError, key); _errorLog.Log(exc, ErrorSeverity.Information); return default; } } public List GetPagedListCacheObject(string rootKey, int page) { rootKey = GetEffectiveCacheKey(rootKey); _cacheTelemetry.Start(); _cache.TryGetValue(rootKey, out Dictionary> rootPages); if (rootPages == null) { _cacheTelemetry.End(CacheTelemetryNames.GetMemoryMiss, rootKey); return null; } _cacheTelemetry.End(CacheTelemetryNames.GetMemoryHit, rootKey); if (rootPages.ContainsKey(page)) return rootPages[page]; return null; } } ================================================ FILE: src/PopForums.AzureKit/Redis/CacheTelemetrySink.cs ================================================ namespace PopForums.AzureKit.Redis; public class CacheTelemetrySink : ICacheTelemetry { public void Start() { } public void End(string eventName, string key) { } } ================================================ FILE: src/PopForums.AzureKit/Redis/ICacheTelemetry.cs ================================================ namespace PopForums.AzureKit.Redis; public interface ICacheTelemetry { void Start(); void End(string eventName, string key); } ================================================ FILE: src/PopForums.AzureKit/Search/SearchIndexSubsystem.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using Azure; using Azure.Search.Documents; using Azure.Search.Documents.Indexes; using Azure.Search.Documents.Indexes.Models; using Azure.Search.Documents.Models; using PopForums.Configuration; using PopForums.Services; namespace PopForums.AzureKit.Search; public class SearchIndexSubsystem : ISearchIndexSubsystem { private readonly ITextParsingService _textParsingService; private readonly IPostService _postService; private readonly IConfig _config; private readonly ITopicService _topicService; private readonly IErrorLog _errorLog; public static string IndexName = "popforumstopics"; public SearchIndexSubsystem(ITextParsingService textParsingService, IPostService postService, IConfig config, ITopicService topicService, IErrorLog errorLog) { _textParsingService = textParsingService; _postService = postService; _config = config; _topicService = topicService; _errorLog = errorLog; } public void DoIndex(int topicID, string tenantID, bool isForRemoval) { if (isForRemoval) { RemoveIndex(topicID, tenantID); return; } var topic = _topicService.Get(topicID).Result; if (topic != null) { var posts = _postService.GetPosts(topic, false).Result.ToArray(); var parsedPosts = posts.Select(x => { var parsedText = _textParsingService.ClientHtmlToForumCode(x.FullText); parsedText = _textParsingService.RemoveForumCode(parsedText); return parsedText; }).ToArray(); var joinedPosts = string.Join(" ", parsedPosts); var searchTopic = new SearchTopic { Key = $"{tenantID}-{topicID}", TopicID = topic.TopicID.ToString(), ForumID = topic.ForumID, Title = topic.Title, LastPostTime = topic.LastPostTime, StartedByName = topic.StartedByName, Replies = topic.ReplyCount, Views = topic.ViewCount, IsClosed = topic.IsClosed, IsPinned = topic.IsPinned, UrlName = topic.UrlName, LastPostName = topic.LastPostName, Posts = joinedPosts, TenantID = tenantID }; var batch = IndexDocumentsBatch.Create(IndexDocumentsAction.Upload(searchTopic)); try { CreateIndex(); var searchClient = new SearchClient(new Uri(_config.SearchUrl), IndexName, new AzureKeyCredential(_config.SearchKey)); searchClient.IndexDocuments(batch); } catch (Exception exc) { _errorLog.Log(exc, ErrorSeverity.Error); } } } public void RemoveIndex(int topicID, string tenantID) { var key = $"{tenantID}-{topicID}"; try { var searchClient = new SearchClient(new Uri(_config.SearchUrl), IndexName, new AzureKeyCredential(_config.SearchKey)); searchClient.DeleteDocuments("key", new[] {key}); } catch (Exception exc) { _errorLog.Log(exc, ErrorSeverity.Error); } } private void CreateIndex() { var searchIndexClient = new SearchIndexClient(new Uri(_config.SearchUrl), new AzureKeyCredential(_config.SearchKey)); var indexResult = searchIndexClient.GetIndexNames(); if (indexResult.Contains(IndexName)) return; var indexDefinition = new SearchIndex(IndexName) { Fields = { new SimpleField("Key", SearchFieldDataType.String) {IsKey = true}, new SimpleField("TopicID", SearchFieldDataType.String), new SimpleField("ForumID", SearchFieldDataType.Int32) {IsFilterable = true}, new SearchableField("Title") {IsSortable = true}, new SimpleField("LastPostTime", SearchFieldDataType.DateTimeOffset) {IsSortable = true}, new SearchableField("StartedByName") {IsSortable = true}, new SimpleField("Replies", SearchFieldDataType.Int32) {IsSortable = true}, new SimpleField("Views", SearchFieldDataType.Int32) {IsSortable = true}, new SimpleField("IsClosed", SearchFieldDataType.Boolean) {IsSortable = false}, new SimpleField("IsPinned", SearchFieldDataType.Boolean) {IsSortable = false}, new SimpleField("UrlName", SearchFieldDataType.String) {IsSortable = false}, new SimpleField("LastPostName", SearchFieldDataType.String) {IsSortable = false}, new SearchableField("Posts") {IsSortable = false}, new SearchableField("TenantID") } }; var weights = new TextWeights(new Dictionary {{"Title", 10}, {"StartedByName", 5}, {"Posts", 1}}); indexDefinition.ScoringProfiles.Add( new ScoringProfile("TopicWeight") {TextWeights = weights}); searchIndexClient.CreateIndex(indexDefinition); } } ================================================ FILE: src/PopForums.AzureKit/Search/SearchRepository.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Azure.Search.Documents; using PopForums.Configuration; using PopForums.Sql; using PopForums.Models; using PopForums.Repositories; using Azure; #pragma warning disable 1998 namespace PopForums.AzureKit.Search; public class SearchRepository : Sql.Repositories.SearchRepository { private readonly IConfig _config; private readonly IErrorLog _errorLog; private readonly ITopicRepository _topicRepository; public SearchRepository(ISqlObjectFactory sqlObjectFactory, IConfig config, IErrorLog errorLog, ITopicRepository topicRepository) : base(sqlObjectFactory) { _config = config; _errorLog = errorLog; _topicRepository = topicRepository; } public override async Task> GetJunkWords() { return new List(); } public override async Task CreateJunkWord(string word) { throw new NotImplementedException(); } public override async Task DeleteJunkWord(string word) { throw new NotImplementedException(); } public override async Task DeleteAllIndexedWordsForTopic(int topicID) { throw new NotImplementedException(); } public override async Task SaveSearchWord(int topicID, string word, int rank) { throw new NotImplementedException(); } public override async Task>, int>> SearchTopics(string searchTerm, List hiddenForums, SearchType searchType, int startRow, int pageSize) { int topicCount; var searchClient = new SearchClient(new Uri(_config.SearchUrl), SearchIndexSubsystem.IndexName, new AzureKeyCredential(_config.SearchKey)); try { var options = new SearchOptions(); switch (searchType) { case SearchType.Date: options.OrderBy.Add("LastPostTime desc"); break; case SearchType.Name: options.OrderBy.Add("StartedByName"); break; case SearchType.Replies: options.OrderBy.Add("Replies desc"); break; case SearchType.Title: options.OrderBy.Add("Title"); break; default: break; } if (startRow > 1) options.Skip = startRow - 1; options.Size = pageSize; if (hiddenForums != null && hiddenForums.Any()) { var neConditions = hiddenForums.Select(x => "ForumID ne " + x); options.Filter = string.Join(" and ", neConditions); } options.IncludeTotalCount = true; options.Select.Add("TopicID"); var result = searchClient.Search(searchTerm, options); var resultModels = result.Value.GetResults(); var topicIDs = resultModels.Select(x => Convert.ToInt32(x.Document.TopicID)); var topics = await _topicRepository.Get(topicIDs); topicCount = Convert.ToInt32(result.Value.TotalCount); return Tuple.Create(new PopForums.Models.Response>(topics), topicCount); } catch (Exception exception) { _errorLog.Log(exception, ErrorSeverity.Error); topicCount = 0; return Tuple.Create(new PopForums.Models.Response>(null, false, exception), topicCount); } } } ================================================ FILE: src/PopForums.AzureKit/Search/SearchTopic.cs ================================================ using System; namespace PopForums.AzureKit.Search; public class SearchTopic { public string Key { get; set; } public string TopicID { get; set; } public int ForumID { get; set; } public string Title { get; set; } public DateTime LastPostTime { get; set; } public string StartedByName { get; set; } public int Replies { get; set; } public int Views { get; set; } public bool IsClosed { get; set; } public bool IsPinned { get; set; } public string UrlName { get; set; } public string LastPostName { get; set; } public string Posts { get; set; } public string TenantID { get; set; } } ================================================ FILE: src/PopForums.AzureKit/ServiceCollectionExtensions.cs ================================================ using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using PopForums.AzureKit.PostImage; using PopForums.AzureKit.Queue; using PopForums.AzureKit.Redis; using PopForums.Configuration; using PopForums.Repositories; using PopForums.Services; namespace PopForums.AzureKit; public static class ServiceCollectionExtensions { public static IServiceCollection AddPopForumsRedisCache(this IServiceCollection services) { services.AddTransient(); var serviceProvider = services.BuildServiceProvider(); var config = serviceProvider.GetService(); if (config.ForceLocalOnly) return services; services.Replace(ServiceDescriptor.Singleton()); return services; } public static ISignalRServerBuilder AddRedisBackplaneForPopForums(this ISignalRServerBuilder signalRServerBuilder) { var serviceProvider = signalRServerBuilder.Services.BuildServiceProvider(); var config = serviceProvider.GetService(); signalRServerBuilder.AddStackExchangeRedis(config.CacheConnectionString); return signalRServerBuilder; } public static IServiceCollection AddPopForumsAzureSearch(this IServiceCollection services) { services.Replace(ServiceDescriptor.Transient()); services.Replace(ServiceDescriptor.Transient()); return services; } public static IServiceCollection AddPopForumsAzureFunctionsAndQueues(this IServiceCollection services) { services.Replace(ServiceDescriptor.Transient()); services.Replace(ServiceDescriptor.Transient()); services.Replace(ServiceDescriptor.Transient()); services.Replace(ServiceDescriptor.Transient()); return services; } public static IServiceCollection AddPopForumsAzureBlobStorageForPostImages(this IServiceCollection services) { services.Replace(ServiceDescriptor.Transient()); return services; } public static IServiceCollection AddPopForumsTableStorageLogging(this IServiceCollection services) { services.Replace(ServiceDescriptor.Transient()); return services; } } ================================================ FILE: src/PopForums.AzureKit.Functions/.gitignore ================================================ ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. # Azure Functions localsettings file local.settings.json # User-specific files *.suo *.user *.userosscache *.sln.docstates # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs # Build results [Dd]ebug/ [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ x64/ x86/ bld/ [Bb]in/ [Oo]bj/ [Ll]og/ # Visual Studio 2015 cache/options directory .vs/ # Uncomment if you have tasks that create the project's static files in wwwroot #wwwroot/ # MSTest test Results [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* # NUNIT *.VisualState.xml TestResult.xml # Build Results of an ATL Project [Dd]ebugPS/ [Rr]eleasePS/ dlldata.c # DNX project.lock.json project.fragment.lock.json artifacts/ *_i.c *_p.c *_i.h *.ilk *.meta *.obj *.pch *.pdb *.pgc *.pgd *.rsp *.sbr *.tlb *.tli *.tlh *.tmp *.tmp_proj *.log *.vspscc *.vssscc .builds *.pidb *.svclog *.scc # Chutzpah Test files _Chutzpah* # Visual C++ cache files ipch/ *.aps *.ncb *.opendb *.opensdf *.sdf *.cachefile *.VC.db *.VC.VC.opendb # Visual Studio profiler *.psess *.vsp *.vspx *.sap # TFS 2012 Local Workspace $tf/ # Guidance Automation Toolkit *.gpState # ReSharper is a .NET coding add-in _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user # JustCode is a .NET coding add-in .JustCode # TeamCity is a build add-in _TeamCity* # DotCover is a Code Coverage Tool *.dotCover # NCrunch _NCrunch_* .*crunch*.local.xml nCrunchTemp_* # MightyMoose *.mm.* AutoTest.Net/ # Web workbench (sass) .sass-cache/ # Installshield output folder [Ee]xpress/ # DocProject is a documentation generator add-in DocProject/buildhelp/ DocProject/Help/*.HxT DocProject/Help/*.HxC DocProject/Help/*.hhc DocProject/Help/*.hhk DocProject/Help/*.hhp DocProject/Help/Html2 DocProject/Help/html # Click-Once directory publish/ # Publish Web Output *.[Pp]ublish.xml *.azurePubxml # TODO: Comment the next line if you want to checkin your web deploy settings # but database connection strings (with potential passwords) will be unencrypted #*.pubxml *.publishproj # Microsoft Azure Web App publish settings. Comment the next line if you want to # checkin your Azure Web App publish settings, but sensitive information contained # in these scripts will be unencrypted PublishScripts/ # NuGet Packages *.nupkg # The packages folder can be ignored because of Package Restore **/packages/* # except build/, which is used as an MSBuild target. !**/packages/build/ # Uncomment if necessary however generally it will be regenerated when needed #!**/packages/repositories.config # NuGet v3's project.json files produces more ignoreable files *.nuget.props *.nuget.targets # Microsoft Azure Build Output csx/ *.build.csdef # Microsoft Azure Emulator ecf/ rcf/ # Windows Store app package directories and files AppPackages/ BundleArtifacts/ Package.StoreAssociation.xml _pkginfo.txt # Visual Studio cache files # files ending in .cache can be ignored *.[Cc]ache # but keep track of directories ending in .cache !*.[Cc]ache/ # Others ClientBin/ ~$* *~ *.dbmdl *.dbproj.schemaview *.jfm *.pfx *.publishsettings node_modules/ orleans.codegen.cs # Since there are multiple workflows, uncomment next line to ignore bower_components # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) #bower_components/ # RIA/Silverlight projects Generated_Code/ # Backup & report files from converting an old project file # to a newer Visual Studio version. Backup files are not needed, # because we have git ;-) _UpgradeReport_Files/ Backup*/ UpgradeLog*.XML UpgradeLog*.htm # SQL Server files *.mdf *.ldf # Business Intelligence projects *.rdl.data *.bim.layout *.bim_*.settings # Microsoft Fakes FakesAssemblies/ # GhostDoc plugin setting file *.GhostDoc.xml # Node.js Tools for Visual Studio .ntvs_analysis.dat # Visual Studio 6 build log *.plg # Visual Studio 6 workspace options file *.opt # Visual Studio LightSwitch build output **/*.HTMLClient/GeneratedArtifacts **/*.DesktopClient/GeneratedArtifacts **/*.DesktopClient/ModelManifest.xml **/*.Server/GeneratedArtifacts **/*.Server/ModelManifest.xml _Pvt_Extensions # Paket dependency manager .paket/paket.exe paket-files/ # FAKE - F# Make .fake/ # JetBrains Rider .idea/ *.sln.iml # CodeRush .cr/ # Python Tools for Visual Studio (PTVS) __pycache__/ *.pyc ================================================ FILE: src/PopForums.AzureKit.Functions/AwardCalculationProcessor.cs ================================================ using System; using System.Diagnostics; using System.Text.Json; using System.Threading.Tasks; using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Logging; using PopForums.AzureKit.Queue; using PopForums.Configuration; using PopForums.Models; using PopForums.ScoringGame; using PopForums.Services; namespace PopForums.AzureKit.Functions; public class AwardCalculationProcessor { private readonly IAwardCalculator _awardCalculator; private readonly IServiceHeartbeatService _serviceHeartbeatService; private readonly IErrorLog _errorLog; public AwardCalculationProcessor(IAwardCalculator awardCalculator, IServiceHeartbeatService serviceHeartbeatService, IErrorLog errorLog) { _awardCalculator = awardCalculator; _serviceHeartbeatService = serviceHeartbeatService; _errorLog = errorLog; } [Function("AwardCalculationProcessor")] public async Task Run([QueueTrigger(AwardCalculationQueueRepository.QueueName)] string jsonPayload, FunctionContext executionContext) { var logger = executionContext.GetLogger("AzureFunction"); var stopwatch = new Stopwatch(); stopwatch.Start(); try { var payload = JsonSerializer.Deserialize(jsonPayload); await _awardCalculator.ProcessCalculation(payload.EventDefinitionID, payload.UserID); } catch (Exception exc) { _errorLog.Log(exc, ErrorSeverity.Error); logger.LogError(exc, $"Exception thrown running {nameof(AwardCalculationProcessor)}"); } stopwatch.Stop(); logger.LogInformation($"C# Queue AwardCalculationProcessor function processed ({stopwatch.ElapsedMilliseconds}ms): {jsonPayload}"); await _serviceHeartbeatService.RecordHeartbeat(typeof(AwardCalculationProcessor).FullName, "AzureFunction"); } } ================================================ FILE: src/PopForums.AzureKit.Functions/BrokerSink.cs ================================================ using PopForums.Messaging; using PopForums.Models; namespace PopForums.AzureKit.Functions; public class BrokerSink : IBroker { public void NotifyNewPosts(Topic topic, int lasPostID) { throw new System.NotImplementedException(); } public void NotifyForumUpdate(Forum forum) { throw new System.NotImplementedException(); } public void NotifyTopicUpdate(Topic topic, Forum forum, string topicLink) { throw new System.NotImplementedException(); } public void NotifyNewPost(Topic topic, int postID) { throw new System.NotImplementedException(); } public void NotifyPMCount(int userID, int pmCount) { throw new System.NotImplementedException(); } public void NotifyUser(Notification notification) { throw new System.NotImplementedException(); } public void NotifyUser(Notification notification, string tenantID) { throw new System.NotImplementedException(); } public void SendPMMessage(PrivateMessagePost post) { throw new System.NotImplementedException(); } } ================================================ FILE: src/PopForums.AzureKit.Functions/CacheHelper.cs ================================================ using System; using System.Collections.Generic; using PopForums.Configuration; namespace PopForums.AzureKit.Functions; public class CacheHelper : ICacheHelper { public void SetCacheObject(string key, object value) { } public void SetCacheObject(string key, object value, double seconds) { } public void SetLongTermCacheObject(string key, object value) { } public void SetPagedListCacheObject(string rootKey, int page, List value) { } public void RemoveCacheObject(string key) { } public T GetCacheObject(string key) { return default; } public List GetPagedListCacheObject(string rootKey, int page) { return null; } #pragma warning disable CS0067 public event Action OnRemoveCacheKey; #pragma warning restore CS0067 public string GetEffectiveCacheKey(string key) { return key; } } ================================================ FILE: src/PopForums.AzureKit.Functions/CloseAgedTopicsProcessor.cs ================================================ using System; using System.Diagnostics; using System.Threading.Tasks; using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Logging; using PopForums.Configuration; using PopForums.Services; namespace PopForums.AzureKit.Functions; public class CloseAgedTopicsProcessor { private readonly ITopicService _topicService; private readonly IServiceHeartbeatService _serviceHeartbeatService; private readonly IErrorLog _errorLog; public CloseAgedTopicsProcessor(ITopicService topicService, IServiceHeartbeatService serviceHeartbeatService, IErrorLog errorLog) { _topicService = topicService; _serviceHeartbeatService = serviceHeartbeatService; _errorLog = errorLog; } [Function("CloseAgedTopicsProcessor")] public async Task Run([TimerTrigger("0 0 */12 * * *")]TimerInfo myTimer, FunctionContext executionContext) { var logger = executionContext.GetLogger("AzureFunction"); var stopwatch = new Stopwatch(); stopwatch.Start(); try { await _topicService.CloseAgedTopics(); } catch (Exception exc) { _errorLog.Log(exc, ErrorSeverity.Error); logger.LogError(exc, $"Exception thrown running {nameof(CloseAgedTopicsProcessor)}"); } stopwatch.Stop(); logger.LogInformation($"C# Timer {nameof(CloseAgedTopicsProcessor)} function executed ({stopwatch.ElapsedMilliseconds}ms) at: {DateTime.UtcNow}"); await _serviceHeartbeatService.RecordHeartbeat(typeof(CloseAgedTopicsProcessor).FullName, "AzureFunction"); } } ================================================ FILE: src/PopForums.AzureKit.Functions/EmailProcessor.cs ================================================ using System; using System.Diagnostics; using System.Text.Json; using System.Threading.Tasks; using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Logging; using PopForums.Configuration; using PopForums.Email; using PopForums.Models; using PopForums.Repositories; using PopForums.Services; namespace PopForums.AzureKit.Functions; public class EmailProcessor { private readonly IQueuedEmailMessageRepository _queuedEmailRepo; private readonly ISmtpWrapper _smtpWrapper; private readonly IServiceHeartbeatService _serviceHeartbeatService; private readonly IErrorLog _errorLog; public EmailProcessor(IQueuedEmailMessageRepository queuedEmailRepo, ISmtpWrapper smtpWrapper, IServiceHeartbeatService serviceHeartbeatService, IErrorLog errorLog) { _queuedEmailRepo = queuedEmailRepo; _smtpWrapper = smtpWrapper; _serviceHeartbeatService = serviceHeartbeatService; _errorLog = errorLog; } [Function("EmailProcessor")] public async Task RunAsync([QueueTrigger(PopForums.AzureKit.Queue.EmailQueueRepository.QueueName)]string jsonPayload, FunctionContext executionContext) { var logger = executionContext.GetLogger("AzureFunction"); var stopwatch = new Stopwatch(); stopwatch.Start(); QueuedEmailMessage message = null; try { var payload = JsonSerializer.Deserialize(jsonPayload); message = _queuedEmailRepo.GetMessage(payload.MessageID).Result; if (payload.EmailQueuePayloadType == EmailQueuePayloadType.MassMessage) { message.ToEmail = payload.ToEmail; message.ToName = payload.ToName; } _smtpWrapper.Send(message); await _queuedEmailRepo.DeleteMessage(message.MessageID); } catch (Exception exc) { if (message == null) _errorLog.Log(exc, ErrorSeverity.Email, "There was no message for the MailWorker to send."); else _errorLog.Log(exc, ErrorSeverity.Email, $"MessageID: {message.MessageID}, To: <{message.ToEmail}> {message.ToName}, Subject: {message.Subject}"); logger.LogError(exc, $"Exception thrown running {nameof(EmailProcessor)}"); } stopwatch.Stop(); logger.LogInformation($"C# Queue {nameof(EmailProcessor)} function processed ({stopwatch.ElapsedMilliseconds}ms): {jsonPayload}"); try { await _serviceHeartbeatService.RecordHeartbeat(typeof(EmailProcessor).FullName, "AzureFunction"); } catch(Exception exc) { // we don't want to risk spamming anyone because of a database failure logger.LogError(exc, $"Logging the service heartbeat for {nameof(EmailProcessor)} failed."); } } } ================================================ FILE: src/PopForums.AzureKit.Functions/NotificationTunnel.cs ================================================ using System; using System.Net.Http; using System.Net.Http.Json; using PopForums.Configuration; using PopForums.Extensions; using PopForums.Messaging; using PopForums.Messaging.Models; namespace PopForums.AzureKit.Functions; public class NotificationTunnel : INotificationTunnel { private readonly IConfig _config; public NotificationTunnel(IConfig config) { _config = config; } public void SendNotificationForUserAward(string title, int userID, string tenantID) { var payload = new AwardPayload { Title = title, UserID = userID, TenantID = tenantID }; var url = _config.WebAppUrlAndArea + "/api/notifyaward"; SendMessage(url, payload); } public void SendNotificationForReply(string postName, string title, int topicID, int userID, string tenantID) { var payload = new ReplyPayload { PostName = postName, Title = title, TopicID = topicID, UserID = userID, TenantID = tenantID }; var url = _config.WebAppUrlAndArea + "/api/notifyreply"; SendMessage(url, payload); } private void SendMessage(string url, object payload) { var authHash = _config.QueueConnectionString.GetSHA256Hash(); using var httpClient = new HttpClient(); httpClient.DefaultRequestHeaders.Add(Messaging.NotificationTunnel.HeaderName, authHash); var result = httpClient.PostAsJsonAsync(url, payload).Result; if (!result.IsSuccessStatusCode) throw new Exception($"Problem sending message over notification tunnel: HTTP {result.StatusCode}"); } } ================================================ FILE: src/PopForums.AzureKit.Functions/PopForums.AzureKit.Functions.csproj ================================================  PopForums AzureKit Functions 22.0.0 Jeff Putz net10.0 PopForums.AzureKit.Functions PopForums.AzureKit.Functions true https://github.com/POPWorldMedia/POPForums https://github.com/POPWorldMedia/POPForums 2025, POP World Media, LLC v4 Exe PreserveNewest PreserveNewest PreserveNewest PreserveNewest Never ================================================ FILE: src/PopForums.AzureKit.Functions/PostImageCleanupProcessor.cs ================================================ using Microsoft.Azure.Functions.Worker; using PopForums.Configuration; using PopForums.Services; using System.Diagnostics; using System.Threading.Tasks; using System; using Microsoft.Extensions.Logging; namespace PopForums.AzureKit.Functions; public class PostImageCleanupProcessor { private readonly IPostImageService _postImageService; private readonly IServiceHeartbeatService _serviceHeartbeatService; private readonly IErrorLog _errorLog; public PostImageCleanupProcessor(IPostImageService postImageService, IServiceHeartbeatService serviceHeartbeatService, IErrorLog errorLog) { _postImageService = postImageService; _serviceHeartbeatService = serviceHeartbeatService; _errorLog = errorLog; } [Function("PostImageCleanupProcessor")] public async Task Run([TimerTrigger("0 5 */1 * * *")] TimerInfo myTimer, FunctionContext executionContext) { var logger = executionContext.GetLogger("AzureFunction"); var stopwatch = new Stopwatch(); stopwatch.Start(); try { await _postImageService.DeleteOldPostImages(); } catch (Exception exc) { _errorLog.Log(exc, ErrorSeverity.Error); logger.LogError(exc, $"Exception thrown running {nameof(PostImageCleanupProcessor)}"); } stopwatch.Stop(); logger.LogInformation($"C# Timer {nameof(PostImageCleanupProcessor)} function executed ({stopwatch.ElapsedMilliseconds}ms) at: {DateTime.UtcNow}"); await _serviceHeartbeatService.RecordHeartbeat(typeof(PostImageCleanupProcessor).FullName, "AzureFunction"); } } ================================================ FILE: src/PopForums.AzureKit.Functions/Program.cs ================================================ using System; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.DependencyInjection; using PopForums.Extensions; using PopForums.Sql; using PopForums.Messaging; using PopForums.Configuration; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection.Extensions; using PopForums.AzureKit; using PopForums.AzureKit.Functions; using PopForums.ElasticKit; using PopForums.Repositories; using NotificationTunnel = PopForums.AzureKit.Functions.NotificationTunnel; var configuration = new ConfigurationBuilder() .SetBasePath(Environment.CurrentDirectory) .AddJsonFile("local.settings.json", true) .AddJsonFile("local.settings.dev.json", true) .AddEnvironmentVariables() .Build(); var config = new Config(configuration); var host = new HostBuilder() .UseDefaultServiceProvider((_, options) => { // there are types not used in functions in core library, so don't choke on them options.ValidateOnBuild = false; }) .ConfigureFunctionsWorkerDefaults() .ConfigureAppConfiguration(c => { c.AddConfiguration(configuration); }) .ConfigureServices(s => { s.AddPopForumsBase(); s.AddPopForumsSql(); s.AddPopForumsAzureFunctionsAndQueues(); s.AddSingleton(); s.RemoveAll(); s.AddSingleton(); s.RemoveAll(); s.AddTransient(); s.RemoveAll(); s.AddTransient(); // use Azure table storage for logging instead of database //s.AddPopForumsTableStorageLogging(); switch (config.SearchProvider.ToLower()) { case "elasticsearch": case "elasticcloud": s.AddPopForumsElasticSearch(); Console.WriteLine("ElasticSearch provider configured."); break; case "azuresearch": s.AddPopForumsAzureSearch(); Console.WriteLine("Azure Search provider configured."); break; default: Console.WriteLine("Default SQL based search provider configured."); break; } }) .Build(); await host.RunAsync(); ================================================ FILE: src/PopForums.AzureKit.Functions/SearchIndexProcessor.cs ================================================ using System; using System.Diagnostics; using System.Text.Json; using System.Threading.Tasks; using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Logging; using PopForums.AzureKit.Queue; using PopForums.Configuration; using PopForums.Models; using PopForums.Services; namespace PopForums.AzureKit.Functions; public class SearchIndexProcessor { private readonly ISearchIndexSubsystem _searchIndexSubsystem; private readonly IServiceHeartbeatService _serviceHeartbeatService; private readonly IErrorLog _errorLog; public SearchIndexProcessor(ISearchIndexSubsystem searchIndexSubsystem, IServiceHeartbeatService serviceHeartbeatService, IErrorLog errorLog) { _searchIndexSubsystem = searchIndexSubsystem; _serviceHeartbeatService = serviceHeartbeatService; _errorLog = errorLog; } [Function("SearchIndexProcessor")] public async Task RunAsync([QueueTrigger(SearchIndexQueueRepository.QueueName)] string jsonPayload, FunctionContext executionContext) { var logger = executionContext.GetLogger("AzureFunction"); var stopwatch = new Stopwatch(); stopwatch.Start(); try { var payload = JsonSerializer.Deserialize(jsonPayload); _searchIndexSubsystem.DoIndex(payload.TopicID, payload.TenantID, payload.IsForRemoval); } catch (Exception exc) { _errorLog.Log(exc, ErrorSeverity.Error); logger.LogError(exc, $"Exception thrown running {nameof(SearchIndexProcessor)}"); } stopwatch.Stop(); logger.LogInformation($"C# Queue SearchIndexProcessor function processed ({stopwatch.ElapsedMilliseconds}ms): {jsonPayload}"); await _serviceHeartbeatService.RecordHeartbeat(typeof(SearchIndexProcessor).FullName, "AzureFunction"); } } ================================================ FILE: src/PopForums.AzureKit.Functions/SubscribeNotificationProcessor.cs ================================================ using Microsoft.Azure.Functions.Worker; using PopForums.Configuration; using PopForums.Models; using PopForums.Services; using System.Diagnostics; using System.Text.Json; using System.Threading.Tasks; using System; using System.Linq; using Microsoft.Extensions.Logging; using PopForums.AzureKit.Queue; using PopForums.Messaging; namespace PopForums.AzureKit.Functions; public class SubscribeNotificationProcessor { private readonly ISubscribedTopicsService _subscribedTopicsService; private readonly IServiceHeartbeatService _serviceHeartbeatService; private readonly IErrorLog _errorLog; private readonly INotificationTunnel _notificationTunnel; public SubscribeNotificationProcessor(ISubscribedTopicsService subscribedTopicsService, IServiceHeartbeatService serviceHeartbeatService, IErrorLog errorLog, INotificationTunnel notificationTunnel) { _subscribedTopicsService = subscribedTopicsService; _serviceHeartbeatService = serviceHeartbeatService; _errorLog = errorLog; _notificationTunnel = notificationTunnel; } [Function("SubscribeNotificationProcessor")] public async Task Run([QueueTrigger(SubscribeNotificationRepository.QueueName)] string jsonPayload, FunctionContext executionContext) { var logger = executionContext.GetLogger("AzureFunction"); var stopwatch = new Stopwatch(); stopwatch.Start(); try { var payload = JsonSerializer.Deserialize(jsonPayload); var userIDs = await _subscribedTopicsService.GetSubscribedUserIDs(payload.TopicID); var filteredUserIDs = userIDs.Where(x => x != payload.PostingUserID); foreach (var userID in filteredUserIDs) _notificationTunnel.SendNotificationForReply(payload.PostingUserName, payload.TopicTitle, payload.TopicID, userID, payload.TenantID); } catch (Exception exc) { _errorLog.Log(exc, ErrorSeverity.Error); logger.LogError(exc, $"Exception thrown running {nameof(SubscribeNotificationProcessor)}"); } stopwatch.Stop(); logger.LogInformation($"C# Queue SubscribeNotificationProcessor function processed ({stopwatch.ElapsedMilliseconds}ms): {jsonPayload}"); await _serviceHeartbeatService.RecordHeartbeat(typeof(SubscribeNotificationProcessor).FullName, "AzureFunction"); } } ================================================ FILE: src/PopForums.AzureKit.Functions/UserSessionProcessor.cs ================================================ using System; using System.Diagnostics; using System.Threading.Tasks; using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Logging; using PopForums.Configuration; using PopForums.Services; namespace PopForums.AzureKit.Functions; public class UserSessionProcessor { private readonly IUserSessionService _userSessionService; private readonly IServiceHeartbeatService _serviceHeartbeatService; private readonly IErrorLog _errorLog; public UserSessionProcessor(IUserSessionService userSessionService, IServiceHeartbeatService serviceHeartbeatService, IErrorLog errorLog) { _userSessionService = userSessionService; _serviceHeartbeatService = serviceHeartbeatService; _errorLog = errorLog; } [Function("UserSessionProcessor")] public async Task RunAsync([TimerTrigger("0 */1 * * * *")]TimerInfo myTimer, FunctionContext executionContext) { var logger = executionContext.GetLogger("AzureFunction"); var stopwatch = new Stopwatch(); stopwatch.Start(); try { await _userSessionService.CleanUpExpiredSessions(); } catch (Exception exc) { _errorLog.Log(exc, ErrorSeverity.Error); logger.LogError(exc, $"Exception thrown running {nameof(UserSessionProcessor)}"); } stopwatch.Stop(); logger.LogInformation($"C# Timer {nameof(UserSessionProcessor)} function executed ({stopwatch.ElapsedMilliseconds}ms) at: {DateTime.UtcNow}"); await _serviceHeartbeatService.RecordHeartbeat(typeof(UserSessionProcessor).FullName, "AzureFunction"); } } ================================================ FILE: src/PopForums.AzureKit.Functions/host.json ================================================ { "version": "2.0" } ================================================ FILE: src/PopForums.ElasticKit/PopForums.ElasticKit.csproj ================================================  PopForums ElasticKit Class Library 22.1.0 Jeff Putz net10.0 PopForums.ElasticKit PopForums.ElasticKit true https://github.com/POPWorldMedia/POPForums https://github.com/POPWorldMedia/POPForums 2025, POP World Media, LLC MIT ================================================ FILE: src/PopForums.ElasticKit/Search/ElasticSearchClientWrapper.cs ================================================ using System; using System.Collections.Generic; using System.Linq; using Elastic.Clients.Elasticsearch; using Elastic.Clients.Elasticsearch.QueryDsl; using Elastic.Transport; using PopForums.Configuration; using PopForums.Models; using PopForums.Services; using SearchType = PopForums.Models.SearchType; namespace PopForums.ElasticKit.Search; public interface IElasticSearchClientWrapper { IndexResponse IndexTopic(SearchTopic searchTopic); Response> SearchTopicsWithIDs(string searchTerm, List hiddenForums, SearchType searchType, int startRow, int pageSize, out int topicCount); void VerifyIndexCreate(); DeleteResponse RemoveTopic(string id); } public class ElasticSearchClientWrapper : IElasticSearchClientWrapper { private readonly IErrorLog _errorLog; private readonly ITenantService _tenantService; private readonly ElasticsearchClient _client; private const string IndexName = "topicindex"; public ElasticSearchClientWrapper(IConfig config, IErrorLog errorLog, ITenantService tenantService) { _errorLog = errorLog; _tenantService = tenantService; ElasticsearchClientSettings settings; switch (config.SearchProvider.ToLower()) { case "elasticsearch": settings = new ElasticsearchClientSettings(new Uri(config.SearchUrl)) .DefaultIndex(IndexName).DisableDirectStreaming() .Authentication(new ApiKey(config.SearchKey)); break; default: settings = new ElasticsearchClientSettings() .DefaultIndex(IndexName).DisableDirectStreaming(); break; } _client = new ElasticsearchClient(settings); } public IndexResponse IndexTopic(SearchTopic searchTopic) { var tenantID = _tenantService.GetTenant(); if (string.IsNullOrWhiteSpace(tenantID)) tenantID = "-"; searchTopic.TenantID = tenantID; var indexResult = _client.Index(searchTopic); return indexResult; } public DeleteResponse RemoveTopic(string id) { var deleteRequest = new DeleteRequest(IndexName, id); var response = _client.Delete(deleteRequest); return response; } public Response> SearchTopicsWithIDs(string searchTerm, List hiddenForums, SearchType searchType, int startRow, int pageSize, out int topicCount) { var sortSelector = new SortOptionsDescriptor(); switch (searchType) { case SearchType.Date: sortSelector.Field(sort => sort.LastPostTime, config => config.Order(SortOrder.Desc)); break; case SearchType.Name: sortSelector.Field(sort => sort.StartedByName, config => config.Order(SortOrder.Asc)); break; case SearchType.Replies: sortSelector.Field(sort => sort.Replies, config => config.Order(SortOrder.Desc)); break; case SearchType.Title: sortSelector.Field(sort => sort.Title, config => config.Order(SortOrder.Asc)); break; default: sortSelector.Score(config => config.Order(SortOrder.Desc)); break; } var tenantID = _tenantService.GetTenant(); if (string.IsNullOrWhiteSpace(tenantID)) tenantID = "-"; startRow--; var searchResponse = _client.Search(s => s .Query(q => q .Bool(bb => bb .Must(ff => ff .MultiMatch(m => m .Query(searchTerm) .Fields(new [] { "title^10", "firstPost^5", "posts" }) .Fuzziness(new Fuzziness("auto"))) ) .MustNot(ff => ff .Terms(m => m .Field(f => f.ForumID) .Terms(new TermsQueryField(hiddenForums.Select(s => (FieldValue)s).ToArray()))) ) .Filter(ff => ff .Term(t => t .Field(f => f.TenantID).Value(tenantID)) ) ) ) .SourceIncludes(new []{"topicID"}) .Sort(sortSelector) .From(startRow) .Size(pageSize)); Response> result; if (!searchResponse.IsValidResponse) { searchResponse.TryGetOriginalException(out var exception); _errorLog.Log(exception, ErrorSeverity.Error, $"Debugging info: {searchResponse.DebugInformation}"); result = new Response>(null, false, exception, searchResponse.DebugInformation); topicCount = 0; return result; } var ids = searchResponse.Documents.Select(d => d.TopicID); topicCount = (int)searchResponse.Total; result = new Response>(ids); return result; } public void VerifyIndexCreate() { var isExists = _client.Indices.Exists(Indices.Index(IndexName)).Exists; if (isExists) return; var createIndexResponse = _client.Indices.Create(IndexName, c => c .Settings(s => s .Analysis(a => a .Analyzers(aa => aa .Standard("standard_english", sa => sa .Stopwords(new List { "_english_" }) ) ) ) ) .Mappings(mm => mm .Properties(p => p .Text(t => t.Posts) .Text(t => t.FirstPost) .Text(t => t.Title, tp => tp.Fielddata()) .Text(t => t.StartedByName, tp => tp.Fielddata()) .Keyword(t => t.TenantID)) ) ); if (!createIndexResponse.IsValidResponse) { createIndexResponse.TryGetOriginalException(out var exception); _errorLog.Log(exception, ErrorSeverity.Error, createIndexResponse.DebugInformation); } } } ================================================ FILE: src/PopForums.ElasticKit/Search/SearchIndexSubsystem.cs ================================================ using System; using System.Linq; using System.Threading.Tasks; using Elastic.Clients.Elasticsearch; using Polly; using Polly.Retry; using PopForums.Configuration; using PopForums.Services; namespace PopForums.ElasticKit.Search; public class SearchIndexSubsystem : ISearchIndexSubsystem { private readonly ITextParsingService _textParsingService; private readonly IPostService _postService; private readonly ITopicService _topicService; private readonly IErrorLog _errorLog; private readonly IElasticSearchClientWrapper _elasticSearchClientWrapper; public SearchIndexSubsystem(ITextParsingService textParsingService, IPostService postService, ITopicService topicService, IErrorLog errorLog, IElasticSearchClientWrapper elasticSearchClientWrapper) { _textParsingService = textParsingService; _postService = postService; _topicService = topicService; _errorLog = errorLog; _elasticSearchClientWrapper = elasticSearchClientWrapper; } public void DoIndex(int topicID, string tenantID, bool isForRemoval) { if (isForRemoval) { RemoveIndex(topicID, tenantID); return; } var topic = _topicService.Get(topicID).Result; if (topic == null) return; _elasticSearchClientWrapper.VerifyIndexCreate(); var posts = _postService.GetPosts(topic, false).Result; if (posts.Count == 0) throw new Exception($"TopicID {topic.TopicID} has no posts to index."); var firstPost = _textParsingService.ClientHtmlToForumCode(posts[0].FullText); firstPost = _textParsingService.RemoveForumCode(firstPost); posts.RemoveAt(0); var parsedPosts = posts.Select(x => { var parsedText = _textParsingService.ClientHtmlToForumCode(x.FullText); parsedText = _textParsingService.RemoveForumCode(parsedText); return parsedText; }).ToArray(); var searchTopic = new SearchTopic { Id = $"{tenantID}-{topic.TopicID}", TopicID = topic.TopicID, ForumID = topic.ForumID, Title = topic.Title, LastPostTime = topic.LastPostTime, StartedByName = topic.StartedByName, Replies = topic.ReplyCount, Views = topic.ViewCount, IsClosed = topic.IsClosed, IsPinned = topic.IsPinned, UrlName = topic.UrlName, LastPostName = topic.LastPostName, FirstPost = firstPost, Posts = parsedPosts, TenantID = tenantID }; try { var pipeline = new ResiliencePipelineBuilder() .AddRetry(new RetryStrategyOptions { ShouldHandle = new PredicateBuilder() .HandleResult(static result => !result.IsValidResponse), DelayGenerator = static args => { var delay = args.AttemptNumber switch { 0 => TimeSpan.FromSeconds(1), 1 => TimeSpan.FromSeconds(5), _ => TimeSpan.FromSeconds(30) }; return new ValueTask(delay); }, OnRetry = responseArgs => { _errorLog.Log(responseArgs.Outcome.Exception, ErrorSeverity.Error, $"Retry after {responseArgs.Duration.Seconds}: {responseArgs.Outcome.Result?.DebugInformation}"); return default; } }).Build(); pipeline.Execute(() => { var indexResult = _elasticSearchClientWrapper.IndexTopic(searchTopic); return indexResult; }); } catch (Exception exc) { _errorLog.Log(exc, ErrorSeverity.Error); } } public void RemoveIndex(int topicID, string tenantID) { var id = $"{tenantID}-{topicID}"; try { var result = _elasticSearchClientWrapper.RemoveTopic(id); if (result.Result != Result.Deleted) { result.TryGetOriginalException(out var exc); _errorLog.Log(exc, ErrorSeverity.Error, $"Debug information: {result.DebugInformation}"); } } catch (Exception exc) { _errorLog.Log(exc, ErrorSeverity.Error); } } } ================================================ FILE: src/PopForums.ElasticKit/Search/SearchRepository.cs ================================================ using System; using System.Collections.Generic; using System.Threading.Tasks; using PopForums.Models; using PopForums.Repositories; using PopForums.Sql; #pragma warning disable 1998 namespace PopForums.ElasticKit.Search; public class SearchRepository : Sql.Repositories.SearchRepository { private readonly ITopicRepository _topicRepository; private readonly IElasticSearchClientWrapper _elasticSearchClientWrapper; public SearchRepository(ISqlObjectFactory sqlObjectFactory, ITopicRepository topicRepository, IElasticSearchClientWrapper elasticSearchClientWrapper) : base(sqlObjectFactory) { _topicRepository = topicRepository; _elasticSearchClientWrapper = elasticSearchClientWrapper; } public override async Task> GetJunkWords() { return new List(); } public override async Task CreateJunkWord(string word) { throw new NotImplementedException(); } public override async Task DeleteJunkWord(string word) { throw new NotImplementedException(); } public override async Task DeleteAllIndexedWordsForTopic(int topicID) { throw new NotImplementedException(); } public override async Task SaveSearchWord(int topicID, string word, int rank) { throw new NotImplementedException(); } public override async Task>, int>> SearchTopics(string searchTerm, List hiddenForums, SearchType searchType, int startRow, int pageSize) { var response = _elasticSearchClientWrapper.SearchTopicsWithIDs(searchTerm, hiddenForums, searchType, startRow, pageSize, out var topicCount); Response> result; if (!response.IsValid) { result = new Response>(null, false, response.Exception, response.DebugInfo); return Tuple.Create(result, topicCount); } var topics = await _topicRepository.Get(response.Data); result = new Response>(topics); return Tuple.Create(result, topicCount); } } ================================================ FILE: src/PopForums.ElasticKit/Search/SearchTopic.cs ================================================ using System; namespace PopForums.ElasticKit.Search; public class SearchTopic { public string Id { get; set; } public int TopicID { get; set; } public int ForumID { get; set; } public string Title { get; set; } public DateTime LastPostTime { get; set; } public string StartedByName { get; set; } public int Replies { get; set; } public int Views { get; set; } public bool IsClosed { get; set; } public bool IsPinned { get; set; } public string UrlName { get; set; } public string LastPostName { get; set; } public string FirstPost { get; set; } public string[] Posts { get; set; } public string TenantID { get; set; } } ================================================ FILE: src/PopForums.ElasticKit/ServiceCollectionExtensions.cs ================================================ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using PopForums.ElasticKit.Search; using PopForums.Repositories; using PopForums.Services; using SearchIndexSubsystem = PopForums.ElasticKit.Search.SearchIndexSubsystem; namespace PopForums.ElasticKit; public static class ServiceCollectionExtensions { public static IServiceCollection AddPopForumsElasticSearch(this IServiceCollection services) { services.Replace(ServiceDescriptor.Transient()); services.Replace(ServiceDescriptor.Transient()); services.AddTransient(); return services; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Authentication/PopForumsAuthenticationDefaults.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Authentication; public static class PopForumsAuthenticationDefaults { public const string AuthenticationScheme = "PopForumsAuthScheme"; public const string ForumsClaimType = "http://popforums.com/forumclaims"; public const string ForumsUserIDType = "http://popforums.com/forumuserid"; } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Authentication/PopForumsAuthenticationIgnoreAttribute.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Authentication; public class PopForumsAuthenticationIgnoreAttribute : Attribute { } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Authentication/PopForumsAuthenticationMiddleware.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Authentication; public class PopForumsAuthenticationMiddleware(RequestDelegate next) { public async Task InvokeAsync(HttpContext context, IUserService userService, IProfileService profileService, ISetupService setupService, IConfig config, IOAuthOnlyService oAuthOnlyService) { var isSetupAndConnectionGood = setupService.IsRuntimeConnectionAndSetupGood(); if (!isSetupAndConnectionGood) { await next.Invoke(context); return; } var endpoint = context.GetEndpoint(); if (endpoint?.Metadata.GetMetadata() is not null) { await next.Invoke(context); return; } var authResult = await context.AuthenticateAsync(PopForumsAuthenticationDefaults.AuthenticationScheme); if (authResult.Principal?.Identity is ClaimsIdentity identity) { var user = await userService.GetUserByName(identity.Name); if (user != null) { if (user.Roles != null && user.Roles.Any()) foreach (var role in user.Roles) identity.AddClaim(new Claim(PopForumsAuthenticationDefaults.ForumsClaimType, role)); identity.AddClaim(new Claim(PopForumsAuthenticationDefaults.ForumsUserIDType, user.UserID.ToString())); context.Items["PopForumsUser"] = user; var profile = await profileService.GetProfile(user); context.Items["PopForumsProfile"] = profile; context.User = new ClaimsPrincipal(identity); if (config.IsOAuthOnly && user.TokenExpiration < DateTime.UtcNow) { var isSuccess = await oAuthOnlyService.AttemptTokenRefresh(user); if (!isSuccess) context.Response.Redirect("/Forums/Account/Login"); } } } await next.Invoke(context); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Authorization/OAuthOnlyForbidAttribute.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Authorization; public class OAuthOnlyForbidAttribute(IConfig config) : Attribute, IResourceFilter { public void OnResourceExecuting(ResourceExecutingContext context) { if (config.IsOAuthOnly) context.Result = new ForbidResult(); } public void OnResourceExecuted(ResourceExecutedContext context) { } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Authorization/PopForumsPrivateForumsFilter.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Authorization; public class PopForumsPrivateForumsFilter(IUserRetrievalShim userRetrievalShim, ISettingsManager settingsManager, IConfig config) : IActionFilter { public void OnActionExecuting(ActionExecutingContext context) { if (!settingsManager.Current.IsPrivateForumInstance && !config.IsOAuthOnly) return; if (userRetrievalShim.GetUser() == null) context.Result = new RedirectToActionResult("Login", AccountController.Name, null); } public void OnActionExecuted(ActionExecutedContext context) { } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Authorization/PopForumsUserAttribute.cs ================================================ using PopForums.Mvc.Areas.Forums.Authentication; namespace PopForums.Mvc.Areas.Forums.Authorization; /// /// This attribute, typically applied globally, is used to track sessions for all users (authenticated or not) /// and drive the "currently online" list and user count. /// /// public class PopForumsUserAttribute(IUserSessionService userSessionService) : IAuthorizationFilter, IAsyncActionFilter { private bool _ignore; protected virtual bool IsGlobalFilter() { return false; } public void OnAuthorization(AuthorizationFilterContext context) { var controllerActionDescriptor = context.ActionDescriptor as ControllerActionDescriptor; if (controllerActionDescriptor == null) return; if (!IsValidToRunOnController(controllerActionDescriptor.ControllerTypeInfo)) { _ignore = true; return; } var attributes = controllerActionDescriptor.MethodInfo.GetCustomAttributes(typeof(PopForumsAuthenticationIgnoreAttribute), false); if (attributes.Any()) { _ignore = true; return; } _ignore = false; } public async Task OnActionExecutionAsync(ActionExecutingContext filterContext, ActionExecutionDelegate next) { if (_ignore) { await next.Invoke(); return; } var userAgents = filterContext.HttpContext.Request.Headers.UserAgent; if (userAgents.Count > 0 && (userAgents[0].ToLower().Contains("bot") || userAgents[0].ToLower().Contains("crawl"))) { await next.Invoke(); return; } if (filterContext.HttpContext.Response.StatusCode == StatusCodes.Status301MovedPermanently || filterContext.HttpContext.Response.StatusCode == StatusCodes.Status302Found) { await next.Invoke(); return; } int.TryParse(filterContext.HttpContext.Request.Cookies[UserSessionService._sessionIDCookieName], out var cookieSessionID); var sessionID = cookieSessionID == 0 ? (int?)null : cookieSessionID; var user = filterContext.HttpContext.Items["PopForumsUser"] as User; await userSessionService.ProcessUserRequest(user, sessionID, filterContext.HttpContext.Connection.RemoteIpAddress.ToString(), () => filterContext.HttpContext.Response.Cookies.Delete(UserSessionService._sessionIDCookieName), s => filterContext.HttpContext.Response.Cookies.Append(UserSessionService._sessionIDCookieName, s.ToString())); await next.Invoke(); } private bool IsValidToRunOnController(TypeInfo controllerType) { if (IsGlobalFilter()) return true; var controllerNamespace = controllerType.Namespace; return controllerNamespace != null && controllerNamespace.StartsWith("PopForums"); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/BackgroundJobs/AwardCalculatorJob.cs ================================================ using System.Threading; using Microsoft.Extensions.Hosting; namespace PopForums.Mvc.Areas.Forums.BackgroundJobs; public class AwardCalculatorJob(ISettingsManager settingsManager, IServiceHeartbeatService serviceHeartbeatService, IAwardCalculatorWorker awardCalculatorWorker, IServiceProvider serviceProvider) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { PeriodicTimer timer; try { timer = new(TimeSpan.FromMilliseconds(settingsManager.Current.ScoringGameCalculatorInterval)); } catch(Exception ex) { var logger = await GetLogger(); logger.LogError(ex, $"Error while executing {GetType().FullName} background job. This job will not restart without restarting the app."); return; } while (!stoppingToken.IsCancellationRequested) { try { awardCalculatorWorker.Execute(); await serviceHeartbeatService.RecordHeartbeat(GetType().FullName, Environment.MachineName); timer.Period = TimeSpan.FromMilliseconds(settingsManager.Current.ScoringGameCalculatorInterval); } catch (Exception ex) { var logger = await GetLogger(); logger.LogError(ex, $"Error while executing {GetType().FullName} background job."); } await timer.WaitForNextTickAsync(stoppingToken); } } private async Task> GetLogger() { await using var scope = serviceProvider.CreateAsyncScope(); var logger = scope.ServiceProvider.GetRequiredService>(); return logger; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/BackgroundJobs/CloseAgedTopicsJob.cs ================================================ using System.Threading; using Microsoft.Extensions.Hosting; namespace PopForums.Mvc.Areas.Forums.BackgroundJobs; public class CloseAgedTopicsJob(IServiceHeartbeatService serviceHeartbeatService, ICloseAgedTopicsWorker closeAgedTopicsWorker, IServiceProvider serviceProvider) : BackgroundService { private const int IntervalValue = 12; private readonly PeriodicTimer _timer = new(TimeSpan.FromHours(IntervalValue)); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { closeAgedTopicsWorker.Execute(); await serviceHeartbeatService.RecordHeartbeat(GetType().FullName, Environment.MachineName); } catch (Exception ex) { var logger = await GetLogger(); logger.LogError(ex, $"Error while executing {GetType().FullName} background job."); } await _timer.WaitForNextTickAsync(stoppingToken); } } private async Task> GetLogger() { await using var scope = serviceProvider.CreateAsyncScope(); var logger = scope.ServiceProvider.GetRequiredService>(); return logger; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/BackgroundJobs/EmailJob.cs ================================================ using System.Threading; using Microsoft.Extensions.Hosting; namespace PopForums.Mvc.Areas.Forums.BackgroundJobs; public class EmailJob(ISettingsManager settingsManager, IServiceHeartbeatService serviceHeartbeatService, IEmailWorker emailWorker, IServiceProvider serviceProvider) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { PeriodicTimer timer; try { timer = new(TimeSpan.FromMilliseconds(settingsManager.Current.MailSendingInverval)); } catch(Exception ex) { var logger = await GetLogger(); logger.LogError(ex, $"Error while executing {GetType().FullName} background job. This job will not restart without restarting the app."); return; } while (!stoppingToken.IsCancellationRequested) { try { #pragma warning disable CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed emailWorker.Execute(); #pragma warning restore CS4014 // Because this call is not awaited, execution of the current method continues before the call is completed await serviceHeartbeatService.RecordHeartbeat(GetType().FullName, Environment.MachineName); var newTimeSpan = TimeSpan.FromMilliseconds(settingsManager.Current.MailSendingInverval); timer.Period = newTimeSpan; } catch (Exception ex) { var logger = await GetLogger(); logger.LogError(ex, $"Error while executing {GetType().FullName} background job."); } await timer.WaitForNextTickAsync(stoppingToken); } } private async Task> GetLogger() { await using var scope = serviceProvider.CreateAsyncScope(); var logger = scope.ServiceProvider.GetRequiredService>(); return logger; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/BackgroundJobs/PostImageCleanupJob.cs ================================================ using System.Threading; using Microsoft.Extensions.Hosting; namespace PopForums.Mvc.Areas.Forums.BackgroundJobs; public class PostImageCleanupJob(IServiceHeartbeatService serviceHeartbeatService, IPostImageCleanupWorker postImageCleanupWorker, IServiceProvider serviceProvider) : BackgroundService { private const double IntervalValue = 12; private readonly PeriodicTimer _timer = new(TimeSpan.FromHours(IntervalValue)); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { postImageCleanupWorker.Execute(); await serviceHeartbeatService.RecordHeartbeat(GetType().FullName, Environment.MachineName); } catch (Exception ex) { var logger = await GetLogger(); logger.LogError(ex, $"Error while executing {GetType().FullName} background job."); } await _timer.WaitForNextTickAsync(stoppingToken); } } private async Task> GetLogger() { await using var scope = serviceProvider.CreateAsyncScope(); var logger = scope.ServiceProvider.GetRequiredService>(); return logger; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/BackgroundJobs/SearchIndexJob.cs ================================================ using System.Threading; using Microsoft.Extensions.Hosting; namespace PopForums.Mvc.Areas.Forums.BackgroundJobs; public class SearchIndexJob(ISettingsManager settingsManager, IServiceHeartbeatService serviceHeartbeatService, ISearchIndexWorker searchIndexWorker, IServiceProvider serviceProvider) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { PeriodicTimer timer; try { timer = new(TimeSpan.FromMilliseconds(settingsManager.Current.SearchIndexingInterval)); } catch(Exception ex) { var logger = await GetLogger(); logger.LogError(ex, $"Error while executing {GetType().FullName} background job. This job will not restart without restarting the app."); return; } while (!stoppingToken.IsCancellationRequested) { try { searchIndexWorker.Execute(); await serviceHeartbeatService.RecordHeartbeat(GetType().FullName, Environment.MachineName); timer.Period = TimeSpan.FromMilliseconds(settingsManager.Current.SearchIndexingInterval); } catch (Exception ex) { var logger = await GetLogger(); logger.LogError(ex, $"Error while executing {GetType().FullName} background job."); } await timer.WaitForNextTickAsync(stoppingToken); } } private async Task> GetLogger() { await using var scope = serviceProvider.CreateAsyncScope(); var logger = scope.ServiceProvider.GetRequiredService>(); return logger; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/BackgroundJobs/SubscribeNotificationJob.cs ================================================ using System.Threading; using Microsoft.Extensions.Hosting; namespace PopForums.Mvc.Areas.Forums.BackgroundJobs; public class SubscribeNotificationJob(IServiceHeartbeatService serviceHeartbeatService, ISubscribeNotificationWorker subscribeNotificationWorker, IServiceProvider serviceProvider) : BackgroundService { private const double IntervalValue = 15; private readonly PeriodicTimer _timer = new(TimeSpan.FromSeconds(IntervalValue)); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { subscribeNotificationWorker.Execute(); await serviceHeartbeatService.RecordHeartbeat(GetType().FullName, Environment.MachineName); } catch (Exception ex) { var logger = await GetLogger(); logger.LogError(ex, $"Error while executing {GetType().FullName} background job."); } await _timer.WaitForNextTickAsync(stoppingToken); } } private async Task> GetLogger() { await using var scope = serviceProvider.CreateAsyncScope(); var logger = scope.ServiceProvider.GetRequiredService>(); return logger; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/BackgroundJobs/UserSessionJob.cs ================================================ using System.Threading; using Microsoft.Extensions.Hosting; namespace PopForums.Mvc.Areas.Forums.BackgroundJobs; public class UserSessionJob(IServiceHeartbeatService serviceHeartbeatService, IUserSessionWorker userSessionWorker, IServiceProvider serviceProvider) : BackgroundService { private const double IntervalValue = 1; private readonly PeriodicTimer _timer = new(TimeSpan.FromMinutes(IntervalValue)); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { try { userSessionWorker.Execute(); await serviceHeartbeatService.RecordHeartbeat(GetType().FullName, Environment.MachineName); } catch (Exception ex) { var logger = await GetLogger(); logger.LogError(ex, $"Error while executing {GetType().FullName} background job."); } await _timer.WaitForNextTickAsync(stoppingToken); } } private async Task> GetLogger() { await using var scope = serviceProvider.CreateAsyncScope(); var logger = scope.ServiceProvider.GetRequiredService>(); return logger; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Controllers/AccountController.cs ================================================ using PopForums.Mvc.Areas.Forums.Authentication; using PopIdentity; namespace PopForums.Mvc.Areas.Forums.Controllers; [Area("Forums")] public class AccountController : Controller { public AccountController(IUserService userService, IProfileService profileService, INewAccountMailer newAccountMailer, ISettingsManager settingsManager, IPostService postService, ITopicService topicService, IForumService forumService, ILastReadService lastReadService, IImageService imageService, IFeedService feedService, IUserAwardService userAwardService, IExternalUserAssociationManager externalUserAssociationManager, IUserRetrievalShim userRetrievalShim, IExternalLoginRoutingService externalLoginRoutingService, IExternalLoginTempService externalLoginTempService, IConfig config, IReCaptchaService reCaptchaService, IOAuthOnlyService oAuthOnlyService) { _userService = userService; _settingsManager = settingsManager; _profileService = profileService; _newAccountMailer = newAccountMailer; _postService = postService; _topicService = topicService; _forumService = forumService; _lastReadService = lastReadService; _imageService = imageService; _feedService = feedService; _userAwardService = userAwardService; _externalUserAssociationManager = externalUserAssociationManager; _userRetrievalShim = userRetrievalShim; _externalLoginRoutingService = externalLoginRoutingService; _externalLoginTempService = externalLoginTempService; _config = config; _reCaptchaService = reCaptchaService; _oAuthOnlyService = oAuthOnlyService; } public static string Name = "Account"; public static string CoppaDateKey = "CoppaDateKey"; public static string TosKey = "TosKey"; private readonly IUserService _userService; private readonly ISettingsManager _settingsManager; private readonly IProfileService _profileService; private readonly INewAccountMailer _newAccountMailer; private readonly IPostService _postService; private readonly ITopicService _topicService; private readonly IForumService _forumService; private readonly ILastReadService _lastReadService; private readonly IImageService _imageService; private readonly IFeedService _feedService; private readonly IUserAwardService _userAwardService; private readonly IExternalUserAssociationManager _externalUserAssociationManager; private readonly IUserRetrievalShim _userRetrievalShim; private readonly IExternalLoginRoutingService _externalLoginRoutingService; private readonly IExternalLoginTempService _externalLoginTempService; private readonly IConfig _config; private readonly IReCaptchaService _reCaptchaService; private readonly IOAuthOnlyService _oAuthOnlyService; [PopForumsAuthenticationIgnore] [TypeFilter(typeof(OAuthOnlyForbidAttribute))] public IActionResult Create() { SetupCreateData(); var signupData = new SignupData { IsSubscribed = true, IsAutoFollowOnReply = true }; var loginState = _externalLoginTempService.Read(); if (loginState?.ResultData != null) { signupData.Email = loginState.ResultData.Email; signupData.Name = loginState.ResultData.Name; } return View(signupData); } private void SetupCreateData() { ViewData[CoppaDateKey] = SignupData.GetCoppaDate(); ViewData[TosKey] = _settingsManager.Current.TermsOfService; } [PopForumsAuthenticationIgnore] [TypeFilter(typeof(OAuthOnlyForbidAttribute))] [HttpPost] public async Task Create(SignupData signupData) { var ip = HttpContext.Connection.RemoteIpAddress.ToString(); if (_config.UseReCaptcha) { var reCaptchaResponse = await _reCaptchaService.VerifyToken(signupData.Token, ip); if (!reCaptchaResponse.IsSuccess) ModelState.AddModelError("Email", Resources.BotError); } await ValidateSignupData(signupData, ModelState, ip); if (ModelState.IsValid) { var user = await _userService.CreateUserWithProfile(signupData, ip); var verifyUrl = Url.Action("Verify", "Account", null, Request.Scheme); var result = _newAccountMailer.Send(user, verifyUrl); if (result != SmtpStatusCode.Ok) ViewData["EmailProblem"] = Resources.EmailProblemAccount + (result?.ToString() ?? "App exception") + "."; if (_settingsManager.Current.IsNewUserApproved) { ViewData["Result"] = Resources.AccountReady; await _userService.Login(user, ip); } else ViewData["Result"] = Resources.AccountReadyCheckEmail; var loginState = _externalLoginTempService.Read(); if (loginState != null) { var externalLoginInfo = new ExternalLoginInfo(loginState.ProviderType.ToString(), loginState.ResultData.ID, loginState.ResultData.Name); await _externalUserAssociationManager.Associate(user, externalLoginInfo, ip); } await IdentityController.PerformSignInAsync(user, HttpContext); return View("AccountCreated"); } SetupCreateData(); return View(signupData); } private async Task ValidateSignupData(SignupData signupData, ModelStateDictionary modelState, string ip) { if (!signupData.IsCoppa) modelState.AddModelError("IsCoppa", Resources.MustBe13); if (!signupData.IsTos) modelState.AddModelError("IsTos", Resources.MustAcceptTOS); var passwordValid = _userService.IsPasswordValid(signupData.Password, out var passwordError); if (!passwordValid) modelState.AddModelError("Password", passwordError); if (signupData.Password != signupData.PasswordRetype) modelState.AddModelError("PasswordRetype", Resources.RetypeYourPassword); if (string.IsNullOrWhiteSpace(signupData.Name)) modelState.AddModelError("Name", Resources.NameRequired); else if (await _userService.IsNameInUse(signupData.Name)) modelState.AddModelError("Name", Resources.NameInUse); if (string.IsNullOrWhiteSpace(signupData.Email)) modelState.AddModelError("Email", Resources.EmailRequired); else if (!signupData.Email.IsEmailAddress()) modelState.AddModelError("Email", Resources.ValidEmailAddressRequired); else if (signupData.Email != null && await _userService.IsEmailInUse(signupData.Email)) modelState.AddModelError("Email", Resources.EmailInUse); if (signupData.Email != null && await _userService.IsEmailBanned(signupData.Email)) modelState.AddModelError("Email", Resources.EmailBanned); if (await _userService.IsIPBanned(ip)) modelState.AddModelError("Email", Resources.IPBanned); } [PopForumsAuthenticationIgnore] [TypeFilter(typeof(OAuthOnlyForbidAttribute))] public async Task Verify(string id) { var authKey = Guid.Empty; if (!string.IsNullOrWhiteSpace(id) && !Guid.TryParse(id, out authKey)) return View("VerifyFail"); if (string.IsNullOrWhiteSpace(id)) return View(); var user = await _userService.VerifyAuthorizationCode(authKey, HttpContext.Connection.RemoteIpAddress.ToString()); if (user == null) return View("VerifyFail"); ViewData["Result"] = Resources.AccountVerified; await _userService.Login(user, HttpContext.Connection.RemoteIpAddress.ToString()); return View(); } [PopForumsAuthenticationIgnore] [TypeFilter(typeof(OAuthOnlyForbidAttribute))] [HttpPost] public RedirectToActionResult VerifyCode(string authorizationCode) { return RedirectToAction("Verify", new { id = authorizationCode }); } [PopForumsAuthenticationIgnore] [TypeFilter(typeof(OAuthOnlyForbidAttribute))] public async Task RequestCode(string email) { var user = await _userService.GetUserByEmail(email); if (user == null) { ViewData["Result"] = Resources.NoUserFoundWithEmail; return View("Verify", new { id = String.Empty }); } var verifyUrl = Url.Action("Verify", "Account", null, Request.Scheme); var result = _newAccountMailer.Send(user, verifyUrl); if (result != SmtpStatusCode.Ok) ViewData["EmailProblem"] = Resources.EmailProblemAccount + result + "."; else ViewData["Result"] = Resources.VerificationEmailSent; return View("Verify", new { id = String.Empty }); } [PopForumsAuthenticationIgnore] public ViewResult Forgot() { return View(); } [PopForumsAuthenticationIgnore] [TypeFilter(typeof(OAuthOnlyForbidAttribute))] [HttpPost] public async Task Forgot(string email) { var user = await _userService.GetUserByEmail(email); if (user == null) { ViewBag.Result = Resources.EmailNotFound; } else { ViewBag.Result = Resources.ForgotInstructionsSent; var resetLink = Url.Action("ResetPassword", "Account", null, Request.Scheme); await _userService.GeneratePasswordResetEmail(user, resetLink); } return View(); } [PopForumsAuthenticationIgnore] [TypeFilter(typeof(OAuthOnlyForbidAttribute))] public async Task ResetPassword(string id) { var authKey = Guid.Empty; if (!string.IsNullOrWhiteSpace(id) && !Guid.TryParse(id, out authKey)) return StatusCode(403); var user = await _userService.GetUserByAuhtorizationKey(authKey); var container = new PasswordResetContainer(); if (user == null) container.IsValidUser = false; else container.IsValidUser = true; return View(container); } [PopForumsAuthenticationIgnore] [TypeFilter(typeof(OAuthOnlyForbidAttribute))] [HttpPost] public async Task ResetPassword(string id, PasswordResetContainer resetContainer) { var authKey = Guid.Empty; if (!string.IsNullOrWhiteSpace(id) && !Guid.TryParse(id, out authKey)) return StatusCode(403); var user = await _userService.GetUserByAuhtorizationKey(authKey); resetContainer.IsValidUser = true; if (resetContainer.Password != resetContainer.PasswordRetype) ModelState.AddModelError("PasswordRetype", Resources.RetypePasswordMustMatch); string errorMessage; _userService.IsPasswordValid(resetContainer.Password, out errorMessage); if (!string.IsNullOrWhiteSpace(errorMessage)) ModelState.AddModelError("Password", errorMessage); if (!ModelState.IsValid) return View(resetContainer); await _userService.ResetPassword(user, resetContainer.Password, HttpContext.Connection.RemoteIpAddress.ToString()); return RedirectToAction("ResetPasswordSuccess"); } [PopForumsAuthenticationIgnore] [TypeFilter(typeof(OAuthOnlyForbidAttribute))] public ActionResult ResetPasswordSuccess() { var user = _userRetrievalShim.GetUser(); if (user == null) return RedirectToAction("Login"); return View(); } public async Task EditProfile() { var user = _userRetrievalShim.GetUser(); if (user == null) return View("EditAccountNoUser"); var profile = await _profileService.GetProfileForEdit(user); var userEdit = new UserEditProfile(profile); return View(userEdit); } [HttpPost] public async Task EditProfile(UserEditProfile userEdit) { var user = _userRetrievalShim.GetUser(); if (user == null) return View("EditAccountNoUser"); await _profileService.EditUserProfile(user, userEdit); ViewBag.Result = Resources.ProfileUpdated; var profile = await _profileService.GetProfileForEdit(user); var newEdit = new UserEditProfile(profile); return View(newEdit); } [TypeFilter(typeof(OAuthOnlyForbidAttribute))] public ViewResult Security() { var user = _userRetrievalShim.GetUser(); if (user == null) return View("EditAccountNoUser"); var isNewUserApproved = _settingsManager.Current.IsNewUserApproved; var userEdit = new UserEditSecurity(user, isNewUserApproved); return View(userEdit); } [TypeFilter(typeof(OAuthOnlyForbidAttribute))] [HttpPost] public async Task ChangePassword(UserEditSecurity userEdit) { var user = _userRetrievalShim.GetUser(); if (user == null) return View("EditAccountNoUser"); var (isPasswordPassed, _) = await _userService.CheckPassword(user.Email, userEdit.OldPassword); if (!isPasswordPassed) ViewBag.PasswordResult = Resources.OldPasswordIncorrect; else if (!userEdit.NewPasswordsMatch()) ViewBag.PasswordResult = Resources.RetypePasswordMustMatch; else if (!_userService.IsPasswordValid(userEdit.NewPassword, out var errorMessage)) ViewBag.PasswordResult = errorMessage; else { await _userService.SetPassword(user, userEdit.NewPassword, HttpContext.Connection.RemoteIpAddress.ToString(), user); ViewBag.PasswordResult = Resources.NewPasswordSaved; } return View("Security", new UserEditSecurity { NewEmail = String.Empty, NewEmailRetype = String.Empty, IsNewUserApproved = _settingsManager.Current.IsNewUserApproved }); } [TypeFilter(typeof(OAuthOnlyForbidAttribute))] [HttpPost] public async Task ChangeEmail(UserEditSecurity userEdit) { var user = _userRetrievalShim.GetUser(); if (user == null) return View("EditAccountNoUser"); if (string.IsNullOrWhiteSpace(userEdit.NewEmail) || !userEdit.NewEmail.IsEmailAddress()) ViewBag.EmailResult = Resources.ValidEmailAddressRequired; else if (userEdit.NewEmail != userEdit.NewEmailRetype) ViewBag.EmailResult = Resources.EmailsMustMatch; else if (await _userService.IsEmailInUseByDifferentUser(user, userEdit.NewEmail)) ViewBag.EmailResult = Resources.EmailInUse; else { await _userService.ChangeEmail(user, userEdit.NewEmail, user, HttpContext.Connection.RemoteIpAddress.ToString()); if (_settingsManager.Current.IsNewUserApproved) ViewBag.EmailResult = Resources.EmailChangeSuccess; else { ViewBag.EmailResult = Resources.VerificationEmailSent; var verifyUrl = Url.Action("Verify", "Account", null, Request.Scheme); var result = _newAccountMailer.Send(user, verifyUrl); if (result != SmtpStatusCode.Ok) ViewBag.EmailResult = Resources.EmailProblemAccount + result; } } return View("Security", new UserEditSecurity { NewEmail = String.Empty, NewEmailRetype = String.Empty, IsNewUserApproved = _settingsManager.Current.IsNewUserApproved }); } public async Task ManagePhotos() { var user = _userRetrievalShim.GetUser(); if (user == null) return View("EditAccountNoUser"); var profile = await _profileService.GetProfile(user); var userEdit = new UserEditPhoto(profile); if (profile.ImageID.HasValue) userEdit.IsImageApproved = await _imageService.IsUserImageApproved(profile.ImageID.Value); return View(userEdit); } [HttpPost] public async Task ManagePhotos(UserEditPhoto userEdit) { var user = _userRetrievalShim.GetUser(); if (user == null) return View("EditAccountNoUser"); byte[] avatarFile = null; if (userEdit.AvatarFile != null) avatarFile = userEdit.AvatarFile.OpenReadStream().ToBytes(); byte[] photoFile = null; if (userEdit.PhotoFile != null) photoFile = userEdit.PhotoFile.OpenReadStream().ToBytes(); await _userService.EditUserProfileImages(user, userEdit.DeleteAvatar, userEdit.DeleteImage, avatarFile, photoFile); return RedirectToAction("ManagePhotos"); } public async Task MiniProfile(int id) { var user = await _userService.GetUser(id); if (user == null) return View("MiniUserNotFound"); var profile = await _profileService.GetProfile(user); UserImage userImage = null; if (profile.ImageID.HasValue) userImage = await _imageService.GetUserImage(profile.ImageID.Value); var model = new DisplayProfile(user, profile, userImage); model.PostCount = await _postService.GetPostCount(user); var viewingUser = _userRetrievalShim.GetUser(); if (viewingUser == null) model.ShowDetails = false; return View(model); } public async Task ViewProfile(int id) { var user = await _userService.GetUser(id); if (user == null) return NotFound(); var profile = await _profileService.GetProfile(user); UserImage userImage = null; if (profile.ImageID.HasValue) userImage = await _imageService.GetUserImage(profile.ImageID.Value); var model = new DisplayProfile(user, profile, userImage); model.PostCount = await _postService.GetPostCount(user); model.Feed = await _feedService.GetFeed(user); model.UserAwards = await _userAwardService.GetAwards(user); var viewingUser = _userRetrievalShim.GetUser(); if (viewingUser == null) model.ShowDetails = false; return View(model); } public async Task Posts(int id, int pageNumber = 1) { var postUser = await _userService.GetUser(id); if (postUser == null) return NotFound(); var includeDeleted = false; var user = _userRetrievalShim.GetUser(); if (user != null && user.IsInRole(PermanentRoles.Moderator)) includeDeleted = true; var titles = _forumService.GetAllForumTitles(); var (topics, pagerContext) = await _topicService.GetTopics(user, postUser, includeDeleted, pageNumber); var container = new PagedTopicContainer { ForumTitles = titles, PagerContext = pagerContext, Topics = topics }; await _lastReadService.GetTopicReadStatus(user, container); ViewBag.PostUserName = postUser.Name; return View(container); } [PopForumsAuthenticationIgnore] public ActionResult Login() { if (_config.IsOAuthOnly) { return Redirect("OAuthLogin"); } var referer = Request.Headers.Referer.ToString(); var link = Url.IsLocalUrl(referer) ? referer : Url.Action("Index", HomeController.Name); ViewBag.Referrer = link; var externalLoginList = _externalLoginRoutingService.GetActiveProviderTypeAndNameDictionary(); return View(externalLoginList); } [PopForumsAuthenticationIgnore] public IActionResult OAuthLogin() { if (_config.IsOAuthOnly) { var identityProviderRedirectUrl = Url.Action(nameof(IdentityController.CallbackHandler), IdentityController.Name, null, Request.Scheme); var redirect = _oAuthOnlyService.GetLoginUrl(identityProviderRedirectUrl); var loginState = new ExternalLoginState {ProviderType = ProviderType.OAuthOnly, ReturnUrl = identityProviderRedirectUrl }; _externalLoginTempService.Persist(loginState); return View("OAuthLogin", redirect); } return RedirectToAction("Login"); } [PopForumsAuthenticationIgnore] public async Task Unsubscribe(int id, string key) { var user = await _userService.GetUser(id); if (user == null || (await _profileService.Unsubscribe(user, key) == false)) return View("UnsubscribeFailure"); return View(); } [TypeFilter(typeof(OAuthOnlyForbidAttribute))] public async Task ExternalLogins() { var user = _userRetrievalShim.GetUser(); if (user == null) return View("EditAccountNoUser"); var externalAssociations = await _externalUserAssociationManager.GetExternalUserAssociations(user); ViewBag.Referrer = Url.Action("ExternalLogins"); return View(externalAssociations); } [TypeFilter(typeof(OAuthOnlyForbidAttribute))] public async Task RemoveExternalLogin(int id) { var user = _userRetrievalShim.GetUser(); if (user == null) return View("EditAccountNoUser"); await _externalUserAssociationManager.RemoveAssociation(user, id, HttpContext.Connection.RemoteIpAddress.ToString()); return RedirectToAction("ExternalLogins"); } public RedirectToActionResult MyProfile() { var user = _userRetrievalShim.GetUser(); if (user == null) return RedirectToAction("Create"); return RedirectToAction("ViewProfile", new {id = user.UserID}); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Controllers/AdminApiController.cs ================================================ using PopForums.Mvc.Areas.Forums.Authentication; namespace PopForums.Mvc.Areas.Forums.Controllers; [Authorize(Policy = PermanentRoles.Admin, AuthenticationSchemes = PopForumsAuthenticationDefaults.AuthenticationScheme)] [Area("Forums")] [Produces("application/json")] [ApiController] public class AdminApiController : Controller { private readonly ISettingsManager _settingsManager; private readonly ICategoryService _categoryService; private readonly IForumService _forumService; private readonly IUserService _userService; private readonly ISearchService _searchService; private readonly IProfileService _profileService; private readonly IUserRetrievalShim _userRetrievalShim; private readonly IImageService _imageService; private readonly IBanService _banService; private readonly IMailingListService _mailingListService; private readonly IEventDefinitionService _eventDefinitionService; private readonly IAwardDefinitionService _awardDefinitionService; private readonly IEventPublisher _eventPublisher; private readonly IIPHistoryService _ipHistoryService; private readonly ISecurityLogService _securityLogService; private readonly IModerationLogService _moderationLogService; private readonly IErrorLog _errorLog; private readonly IServiceHeartbeatService _serviceHeartbeatService; public AdminApiController(ISettingsManager settingsManager, ICategoryService categoryService, IForumService forumService, IUserService userService, ISearchService searchService, IProfileService profileService, IUserRetrievalShim userRetrievalShim, IImageService imageService, IBanService banService, IMailingListService mailingListService, IEventDefinitionService eventDefinitionService, IAwardDefinitionService awardDefinitionService, IEventPublisher eventPublisher, IIPHistoryService ipHistoryService, ISecurityLogService securityLogService, IModerationLogService moderationLogService, IErrorLog errorLog, IServiceHeartbeatService serviceHeartbeatService) { _settingsManager = settingsManager; _categoryService = categoryService; _forumService = forumService; _userService = userService; _searchService = searchService; _profileService = profileService; _userRetrievalShim = userRetrievalShim; _imageService = imageService; _banService = banService; _mailingListService = mailingListService; _eventDefinitionService = eventDefinitionService; _awardDefinitionService = awardDefinitionService; _eventPublisher = eventPublisher; _ipHistoryService = ipHistoryService; _securityLogService = securityLogService; _moderationLogService = moderationLogService; _errorLog = errorLog; _serviceHeartbeatService = serviceHeartbeatService; } // ********** settings [HttpGet("/Forums/AdminApi/GetSettings")] public ActionResult GetSettings() { var settings = _settingsManager.Current; return settings; } [HttpPost("/Forums/AdminApi/SaveSettings")] public ActionResult SaveSettings([FromBody]Settings settings) { _settingsManager.Save(settings); var newSettings = _settingsManager.Current; return newSettings; } // ********** categories [HttpGet("/Forums/AdminApi/GetCategories")] public async Task>> GetCategories() { var categories = await _categoryService.GetAll(); return categories; } [HttpPost("/Forums/AdminApi/AddCategory")] public async Task>> AddCategory([FromBody]Category category) { await _categoryService.Create(category.Title); var categories = await _categoryService.GetAll(); return categories; } [HttpPost("/Forums/AdminApi/DeleteCategory/{id}")] public async Task>> DeleteCategory(int id) { await _categoryService.Delete(id); var categories = await _categoryService.GetAll(); return categories; } [HttpPost("/Forums/AdminApi/MoveCategoryUp/{id}")] public async Task>> MoveCategoryUp(int id) { await _categoryService.MoveCategoryUp(id); var categories = await _categoryService.GetAll(); return categories; } [HttpPost("/Forums/AdminApi/MoveCategoryDown/{id}")] public async Task>> MoveCategoryDown(int id) { await _categoryService.MoveCategoryDown(id); var categories = await _categoryService.GetAll(); return categories; } [HttpPost("/Forums/AdminApi/EditCategory")] public async Task>> EditCategory([FromBody]Category category) { await _categoryService.UpdateTitle(category.CategoryID, category.Title); var categories = await _categoryService.GetAll(); return categories; } // ********** forums [HttpGet("/Forums/AdminApi/GetForums")] public async Task>> GetForums() { var forums = await _forumService.GetCategoryContainersWithForums(); return forums; } [HttpPost("/Forums/AdminApi/MoveForumUp/{id}")] public async Task>> MoveForumUp(int id) { await _forumService.MoveForumUp(id); var forums = await _forumService.GetCategoryContainersWithForums(); return forums; } [HttpPost("/Forums/AdminApi/MoveForumDown/{id}")] public async Task>> MoveForumDown(int id) { await _forumService.MoveForumDown(id); var forums = await _forumService.GetCategoryContainersWithForums(); return forums; } [HttpPost("/Forums/AdminApi/SaveForum")] public async Task>> SaveForum([FromBody]Forum forumEdit) { if (forumEdit.CategoryID == 0) forumEdit.CategoryID = null; if (forumEdit.ForumID == 0) await _forumService.Create(forumEdit.CategoryID, forumEdit.Title, forumEdit.Description, forumEdit.IsVisible, forumEdit.IsArchived, -1, forumEdit.ForumAdapterName, forumEdit.IsQAForum); else { var forum = await _forumService.Get(forumEdit.ForumID); if (forum == null) return NotFound(); await _forumService.Update(forum, forumEdit.CategoryID, forumEdit.Title, forumEdit.Description, forumEdit.IsVisible, forumEdit.IsArchived, forumEdit.ForumAdapterName, forumEdit.IsQAForum); } var forums = await _forumService.GetCategoryContainersWithForums(); return forums; } // ********** forum permissions [HttpGet("/Forums/AdminApi/GetForumPermissions/{id}")] public async Task> GetForumPermissions(int id) { var forum = await _forumService.Get(id); if (forum == null) return NotFound(); var container = new ForumPermissionContainer { ForumID = forum.ForumID, AllRoles = await _userService.GetAllRoles(), PostRoles = await _forumService.GetForumPostRoles(forum), ViewRoles = await _forumService.GetForumViewRoles(forum) }; return container; } [HttpPost("/Forums/AdminApi/ModifyForumRoles")] public async Task ModifyForumRoles(ModifyForumRolesContainer container) { await _forumService.ModifyForumRoles(container); return NoContent(); } // ********** search [HttpGet("/Forums/AdminApi/GetJunkWords")] public async Task>> GetJunkWords() { var words = await _searchService.GetJunkWords(); return words; } [HttpPost("/Forums/AdminApi/CreateJunkWord/{word}")] public async Task CreateJunkWord(string word) { await _searchService.CreateJunkWord(word); return NoContent(); } [HttpPost("/Forums/AdminApi/DeleteJunkWord/{word}")] public async Task DeleteJunkWord(string word) { await _searchService.DeleteJunkWord(word); return NoContent(); } // ********** recent users [HttpGet("/Forums/AdminApi/GetRecentUsers")] public async Task>> GetRecentUsers() { var userResults = await _userService.GetRecentUsers(); return userResults; } // ********** edit user [HttpPost("/Forums/AdminApi/EditUserSearch")] public async Task>> EditUserSearch(UserSearch userSearch) { List users; switch (userSearch.SearchType) { case UserSearch.UserSearchType.Email: users = await _userService.SearchByEmail(userSearch.SearchText); break; case UserSearch.UserSearchType.Name: users = await _userService.SearchByName(userSearch.SearchText); break; case UserSearch.UserSearchType.Role: users = await _userService.SearchByRole(userSearch.SearchText); break; default: throw new ArgumentOutOfRangeException(nameof(userSearch)); } return users; } [HttpGet("/Forums/AdminApi/GetUser/{id}")] public async Task> GetUser(int id) { var user = await _userService.GetUser(id); if (user == null) return NotFound(); var profile = await _profileService.GetProfileForEdit(user, true); var model = new UserEdit(user, profile); return model; } [HttpPost("/Forums/AdminApi/UpdateUserAvatar/{id}")] public async Task> UpdateUserAvatar(int id) { var user = await _userService.GetUser(id); if (user == null) return NotFound(); if (Request.Form?.Files?.Count != 1) { await _userService.EditUserProfileImages(user, true, false, null, null); return new {AvatarID = (int?)null}; } var file = Request.Form.Files[0]; await _userService.EditUserProfileImages(user, false, false, file.OpenReadStream().ToBytes(), null); var profile = await _profileService.GetProfileForEdit(user, true); return new {profile.AvatarID}; } [HttpPost("/Forums/AdminApi/UpdateUserImage/{id}")] public async Task> UpdateUserImage(int id) { var user = await _userService.GetUser(id); if (user == null) return NotFound(); if (Request.Form?.Files?.Count != 1) { await _userService.EditUserProfileImages(user, false, true, null, null); return new { ImageID = (int?)null }; } var file = Request.Form.Files[0]; await _userService.EditUserProfileImages(user, false, false, null, file.OpenReadStream().ToBytes()); var profile = await _profileService.GetProfile(user); return new { profile.ImageID }; } [HttpPost("/Forums/AdminApi/SaveUser")] public async Task SaveUser([FromBody] UserEdit userEdit) { var adminUser = _userRetrievalShim.GetUser(); var ip = HttpContext.Connection.RemoteIpAddress.ToString(); var user = await _userService.GetUser(userEdit.UserID); await _userService.EditUser(user, userEdit, false, false, null, null, ip, adminUser); return Ok(); } [TypeFilter(typeof(OAuthOnlyForbidAttribute))] [HttpPost("/Forums/AdminApi/DeleteUser/{id}")] public async Task DeleteUser(int id) { await DeleteUser(id, false); return Ok(); } [TypeFilter(typeof(OAuthOnlyForbidAttribute))] [HttpPost("/Forums/AdminApi/DeleteAndBanUser/{id}")] public async Task DeleteAndBanUser(int id) { await DeleteUser(id, true); return Ok(); } private async Task DeleteUser(int userID, bool isBanned) { var adminUser = _userRetrievalShim.GetUser(); var ip = HttpContext.Connection.RemoteIpAddress.ToString(); var user = await _userService.GetUser(userID); await _userService.DeleteUser(user, adminUser, ip, isBanned); } // ********** user roles [HttpGet("/Forums/AdminApi/GetAllRoles")] public async Task>> GetAllRoles() { var roles = await _userService.GetAllRoles(); return roles; } [TypeFilter(typeof(OAuthOnlyForbidAttribute))] [HttpPost("/Forums/AdminApi/CreateRole/{role}")] public async Task CreateRole(string role) { var user = _userRetrievalShim.GetUser(); var ip = HttpContext.Connection.RemoteIpAddress.ToString(); await _userService.CreateRole(role, user, ip); return NoContent(); } [TypeFilter(typeof(OAuthOnlyForbidAttribute))] [HttpPost("/Forums/AdminApi/DeleteRole/{role}")] public async Task DeleteRole(string role) { if (role == PermanentRoles.Admin || role == PermanentRoles.Moderator) return NoContent(); var user = _userRetrievalShim.GetUser(); var ip = HttpContext.Connection.RemoteIpAddress.ToString(); await _userService.DeleteRole(role, user, ip); return NoContent(); } // ********** user image approval [HttpGet("/Forums/AdminApi/GetImageApproval")] public async Task> GetImageApproval() { var container = await _imageService.GetUnapprovedUserImageContainer(); return container; } [HttpPost("/Forums/AdminApi/ApproveUserImage/{id}")] public async Task ApproveUserImage(int id) { await _imageService.ApproveUserImage(id); return NoContent(); } [HttpPost("/Forums/AdminApi/DeleteUserImage/{id}")] public async Task DeleteUserImage(int id) { await _imageService.DeleteUserImage(id); return NoContent(); } // ********** email ip ban [TypeFilter(typeof(OAuthOnlyForbidAttribute))] [HttpGet("/Forums/AdminApi/GetEmailIPBan")] public async Task> GetEmailIPBan() { var emails = await _banService.GetEmailBans(); var ips = await _banService.GetIPBans(); var container = new {emails, ips}; return container; } [TypeFilter(typeof(OAuthOnlyForbidAttribute))] [HttpPost("/Forums/AdminApi/BanEmail")] public async Task BanEmail([FromBody] SingleString val) { await _banService.BanEmail(val.String); return NoContent(); } [TypeFilter(typeof(OAuthOnlyForbidAttribute))] [HttpPost("/Forums/AdminApi/RemoveEmail")] public async Task RemoveEmail([FromBody] SingleString val) { await _banService.RemoveEmailBan(val.String); return NoContent(); } [TypeFilter(typeof(OAuthOnlyForbidAttribute))] [HttpPost("/Forums/AdminApi/BanIP")] public async Task BanIP([FromBody] SingleString val) { await _banService.BanIP(val.String); return NoContent(); } [TypeFilter(typeof(OAuthOnlyForbidAttribute))] [HttpPost("/Forums/AdminApi/RemoveIP")] public async Task RemoveIP([FromBody] SingleString val) { await _banService.RemoveIPBan(val.String); return NoContent(); } // ********** email users [TypeFilter(typeof(OAuthOnlyForbidAttribute))] [HttpPost("/Forums/AdminApi/EmailUsers")] public ActionResult EmailUsers([FromBody]EmailUsersContainer container) { if (string.IsNullOrWhiteSpace(container.Subject) || string.IsNullOrWhiteSpace(container.Body)) return StatusCode((int)HttpStatusCode.BadRequest, new {Error = Resources.SubjectAndBodyNotEmpty}); var baseString = Url.Action("Unsubscribe", AccountController.Name, new { id = "--id--", key = "--key--" }, Request.Scheme); baseString = baseString.Replace("--id--", "{0}").Replace("--key--", "{1}"); string UnsubscribeLinkGenerator(User user) => string.Format(baseString, user.UserID, _profileService.GetUnsubscribeHash(user)); _mailingListService.MailUsers(container.Subject, container.Body, container.HtmlBody, UnsubscribeLinkGenerator); return Ok(); } // ********** event definitions [HttpGet("/Forums/AdminApi/GetAllEventDefinitions")] public async Task> GetAllEventDefinitions() { var events = await _eventDefinitionService.GetAll(); var staticIDs = EventDefinitionService.StaticEvents.Select(x => x.Key).ToArray(); var container = new {AllEvents = events, StaticIDs = staticIDs}; return container; } [HttpPost("/Forums/AdminApi/CreateEvent")] public async Task CreateEvent([FromBody]EventDefinition newEvent) { await _eventDefinitionService.Create(newEvent); return Ok(); } [HttpPost("/Forums/AdminApi/DeleteEvent/{id}")] public async Task DeleteEvent(string id) { await _eventDefinitionService.Delete(id); return Ok(); } // ********** award definitions [HttpGet("/Forums/AdminApi/GetAllAwardDefinitions")] public async Task>> GetAllAwardDefinitions() { var awardDefinitions = await _awardDefinitionService.GetAll(); return awardDefinitions; } [HttpPost("/Forums/AdminApi/CreateAward")] public async Task CreateAward([FromBody]AwardDefinition newAward) { await _awardDefinitionService.Create(newAward); return Ok(); } [HttpPost("/Forums/AdminApi/DeleteAward/{id}")] public async Task DeleteAward(string id) { await _awardDefinitionService.Delete(id); return Ok(); } [HttpGet("/Forums/AdminApi/GetAward/{id}")] public async Task> GetAward(string id) { var award = await _awardDefinitionService.Get(id); var conditions = await _awardDefinitionService.GetConditions(award.AwardDefinitionID); var allEvents = await _eventDefinitionService.GetAll(); var container = new {Award = award, Conditions = conditions, AllEvents = allEvents}; return container; } [HttpPost("/Forums/AdminApi/CreateCondition")] public async Task CreateCondition([FromBody]AwardCondition newCondition) { await _awardDefinitionService.AddCondition(newCondition); return Ok(); } [HttpPost("/Forums/AdminApi/DeleteCondition")] public async Task DeleteCondition([FromBody]AwardConditionDeleteContainer container) { await _awardDefinitionService.DeleteCondition(container.AwardDefinitionID, container.EventDefinitionID); return Ok(); } // ********** manual event [HttpPost("/Forums/AdminApi/GetNames")] public async Task>> GetNames(SingleString name) { var users = await _userService.SearchByName(name.String); var projection = users.Select(u => new { u.UserID, u.Name }).ToArray(); return projection; } [HttpGet("/Forums/AdminApi/GetAllEvents")] public async Task>> GetAllEvents() { var events = await _eventDefinitionService.GetAll(); return events; } [HttpPost("/Forums/AdminApi/CreateManualEvent")] public async Task CreateManualEvent([FromBody] ManualEvent manualEvent) { if (!string.IsNullOrEmpty(manualEvent.EventDefinitionID)) return BadRequest("Can't specify an EventDefinitionID."); var user = await _userService.GetUser(manualEvent.UserID); if (user == null) return BadRequest($"UserID {manualEvent.UserID} does not exist."); if (!manualEvent.Points.HasValue) return BadRequest("Point value required."); await _eventPublisher.ProcessManualEvent(manualEvent.Message, user, manualEvent.Points.Value); return Ok(); } [HttpPost("/Forums/AdminApi/CreateExistingManualEvent")] public async Task CreateExistingManualEvent([FromBody] ManualEvent manualEvent) { if (string.IsNullOrEmpty(manualEvent.EventDefinitionID)) return BadRequest("Must specify an EventDefinitionID."); var user = await _userService.GetUser(manualEvent.UserID); if (user == null) return BadRequest($"UserID {manualEvent.UserID} does not exist."); if (manualEvent.Points.HasValue) return BadRequest("Point value can't be specified."); await _eventPublisher.ProcessEvent(manualEvent.Message, user, manualEvent.EventDefinitionID, false); return Ok(); } // ********** ip history [HttpPost("/Forums/AdminApi/QueryIPHistory")] public async Task>> QueryIPHistory([FromBody] IPHistoryQuery query) { var history = await _ipHistoryService.GetHistory(query.IP, query.Start, query.End); return history; } // ********** security log [HttpPost("/Forums/AdminApi/QuerySecurityLog")] public async Task>> QuerySecurityLog([FromBody] SecurityLogQuery query) { List list; switch (query.Type.ToLower()) { case "userid": list = await _securityLogService.GetLogEntriesByUserID(Convert.ToInt32(query.SearchTerm), query.Start, query.End); break; case "name": list = await _securityLogService.GetLogEntriesByUserName(query.SearchTerm, query.Start, query.End); break; default: return BadRequest("Search type invalid."); } return list; } // ********** moderation log [HttpPost("/Forums/AdminApi/QueryModerationLog")] public async Task>> QueryModerationLog([FromBody] IPHistoryQuery query) { var history = await _moderationLogService.GetLog(query.Start, query.End); return history; } // ********** error log [HttpGet("/Forums/AdminApi/GetErrorLog/{pageNumber}")] public ActionResult> GetErrorLog(int pageNumber) { var list = _errorLog.GetErrors(pageNumber, 20); return list; } [HttpPost("/Forums/AdminApi/DeleteAllErrors")] public async Task DeleteAllErrors() { await _errorLog.DeleteAllErrors(); return Ok(); } // ********** error log [HttpGet("/Forums/AdminApi/GetServices")] public async Task>> GetServices() { var list = await _serviceHeartbeatService.GetAll(); return list; } [HttpPost("/Forums/AdminApi/ClearServices")] public async Task>> ClearServices() { await _serviceHeartbeatService.ClearAll(); var list = await _serviceHeartbeatService.GetAll(); return list; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Controllers/AdminController.cs ================================================ using PopForums.Mvc.Areas.Forums.Authentication; namespace PopForums.Mvc.Areas.Forums.Controllers; [Authorize(Policy = PermanentRoles.Admin, AuthenticationSchemes = PopForumsAuthenticationDefaults.AuthenticationScheme)] [Area("Forums")] public class AdminController : Controller { public static string Name = "Admin"; public ViewResult App(string vue = "") { return View(); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Controllers/ApiController.cs ================================================ using PopForums.Messaging.Models; namespace PopForums.Mvc.Areas.Forums.Controllers; [Area("Forums")] [ApiController] public class ApiController : Controller { private readonly INotificationAdapter _notificationAdapter; private readonly IConfig _config; public ApiController(INotificationAdapter notificationAdapter, IConfig config) { _notificationAdapter = notificationAdapter; _config = config; } [HttpPost("/Forums/Api/NotifyAward")] public async Task NotifyAward(AwardPayload awardPayload) { var hash = _config.QueueConnectionString.GetSHA256Hash(); var result = HttpContext.Request.Headers.TryGetValue(NotificationTunnel.HeaderName, out var headerValue); if (headerValue != hash) return Unauthorized(); if (awardPayload == null) return BadRequest(); await _notificationAdapter.Award(awardPayload.Title, awardPayload.UserID, awardPayload.TenantID); return Ok(); } [HttpPost("/Forums/Api/NotifyReply")] public async Task NotifyReply(ReplyPayload replyPayload) { var hash = _config.QueueConnectionString.GetSHA256Hash(); var result = HttpContext.Request.Headers.TryGetValue(NotificationTunnel.HeaderName, out var headerValue); if (headerValue != hash) return Unauthorized(); if (replyPayload == null) return BadRequest(); await _notificationAdapter.Reply(replyPayload.PostName, replyPayload.Title, replyPayload.TopicID, replyPayload.UserID, replyPayload.TenantID); return Ok(); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Controllers/FavoritesController.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Controllers; [Area("Forums")] [TypeFilter(typeof(PopForumsPrivateForumsFilter))] public class FavoritesController : Controller { public FavoritesController(IFavoriteTopicService favoriteTopicService, IForumService forumService, ILastReadService lastReadService, ITopicService topicService, IUserRetrievalShim userRetrievalShim) { _favoriteTopicService = favoriteTopicService; _forumService = forumService; _lastReadService = lastReadService; _topicService = topicService; _userRetrievalShim = userRetrievalShim; } private readonly IFavoriteTopicService _favoriteTopicService; private readonly IForumService _forumService; private readonly ILastReadService _lastReadService; private readonly ITopicService _topicService; private readonly IUserRetrievalShim _userRetrievalShim; public static string Name = "Favorites"; public async Task Topics(int pageNumber = 1) { var user = _userRetrievalShim.GetUser(); if (user == null) return View(); var (topics, pagerContext) = await _favoriteTopicService.GetTopics(user, pageNumber); var titles = _forumService.GetAllForumTitles(); var container = new PagedTopicContainer { PagerContext = pagerContext, Topics = topics, ForumTitles = titles }; await _lastReadService.GetTopicReadStatus(user, container); return View(container); } [HttpPost] public async Task RemoveFavorite(int id) { var user = _userRetrievalShim.GetUser(); var topic = await _topicService.Get(id); await _favoriteTopicService.RemoveFavoriteTopic(user, topic); return RedirectToAction("Topics"); } [HttpPost] public async Task ToggleFavorite(int id) { var user = _userRetrievalShim.GetUser(); if (user == null) return Json(new BasicJsonMessage { Message = Resources.NotLoggedIn, Result = false }); var topic = await _topicService.Get(id); if (topic == null) return Json(new BasicJsonMessage { Message = Resources.TopicNotExist, Result = false }); if (await _favoriteTopicService.IsTopicFavorite(user.UserID, topic.TopicID)) { await _favoriteTopicService.RemoveFavoriteTopic(user, topic); return Json(new BasicJsonMessage { Data = new { isFavorite = false }, Result = true }); } await _favoriteTopicService.AddFavoriteTopic(user, topic); return Json(new BasicJsonMessage { Data = new { isFavorite = true }, Result = true }); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Controllers/ForumController.cs ================================================ using PopForums.Composers; namespace PopForums.Mvc.Areas.Forums.Controllers; [Area("Forums")] [TypeFilter(typeof(PopForumsPrivateForumsFilter))] public class ForumController : Controller { public ForumController(ISettingsManager settingsManager, IForumService forumService, ITopicService topicService, IPostService postService, ITopicViewCountService topicViewCountService, ILastReadService lastReadService, IProfileService profileService, IUserRetrievalShim userRetrievalShim, ITopicViewLogService topicViewLogService, IPostMasterService postMasterService, IForumPermissionService forumPermissionService, ITopicStateComposer topicStateComposer, IForumStateComposer forumStateComposer, IIgnoreService ignoreService) { _settingsManager = settingsManager; _forumService = forumService; _topicService = topicService; _postService = postService; _topicViewCountService = topicViewCountService; _lastReadService = lastReadService; _profileService = profileService; _userRetrievalShim = userRetrievalShim; _topicViewLogService = topicViewLogService; _postMasterService = postMasterService; _forumPermissionService = forumPermissionService; _topicStateComposer = topicStateComposer; _forumStateComposer = forumStateComposer; _ignoreService = ignoreService; } public static string Name = "Forum"; private readonly ISettingsManager _settingsManager; private readonly IForumService _forumService; private readonly ITopicService _topicService; private readonly IPostService _postService; private readonly ITopicViewCountService _topicViewCountService; private readonly ILastReadService _lastReadService; private readonly IProfileService _profileService; private readonly IUserRetrievalShim _userRetrievalShim; private readonly ITopicViewLogService _topicViewLogService; private readonly IPostMasterService _postMasterService; private readonly IForumPermissionService _forumPermissionService; private readonly ITopicStateComposer _topicStateComposer; private readonly IForumStateComposer _forumStateComposer; private readonly IIgnoreService _ignoreService; public async Task Index(string urlName, int pageNumber = 1) { if (string.IsNullOrWhiteSpace(urlName)) return NotFound(); var forum = await _forumService.Get(urlName); if (forum == null) return NotFound(); var user = _userRetrievalShim.GetUser(); var permissionContext = await _forumPermissionService.GetPermissionContext(forum, user); if (!permissionContext.UserCanView) { return StatusCode(403); } var (topics, pagerContext) = await _topicService.GetTopics(forum, permissionContext.UserCanModerate, pageNumber); var container = new ForumTopicContainer { Forum = forum, Topics = topics, PagerContext = pagerContext, PermissionContext = permissionContext }; await _lastReadService.GetTopicReadStatus(user, container); var forumState = _forumStateComposer.GetState(forum, pagerContext); container.ForumState = forumState; var adapter = new ForumAdapterFactory(forum); if (adapter.IsAdapterEnabled) { await adapter.ForumAdapter.AdaptForum(this, container); if (string.IsNullOrWhiteSpace(adapter.ForumAdapter.ViewName)) return View(adapter.ForumAdapter.Model); return View(adapter.ForumAdapter.ViewName, adapter.ForumAdapter.Model); } if (forum.IsQAForum) return View("IndexQA", container); return View(container); } public async Task PostTopic(int id) { var user = _userRetrievalShim.GetUser(); if (user == null) return Content(Resources.LoginToPost); var (forum, permissionContext) = await GetForumByIdWithPermissionContext(id, user); if (!permissionContext.UserCanView) return Content(Resources.ForumNoView); if (!permissionContext.UserCanPost) return Content(Resources.ForumNoPost); var profile = await _profileService.GetProfile(user); var newPost = new NewPost { ItemID = forum.ForumID, IncludeSignature = profile.Signature.Length > 0, IsPlainText = profile.IsPlainText, IsImageEnabled = _settingsManager.Current.AllowImages }; return View("NewTopic", newPost); } [HttpPost] public async Task PostTopic([FromBody]NewPost newPost) { var user = _userRetrievalShim.GetUser(); if (user == null) return Forbid(); var userProfileUrl = Url.Action("ViewProfile", "Account", new { id = user.UserID }); string TopicLinkGenerator(Topic t) => Url.Action("Topic", "Forum", new {id = t.UrlName}); string RedirectLinkGenerator(Topic t) => Url.RouteUrl(new {controller = "Forum", action = "Topic", id = t.UrlName}); var ip = HttpContext.Connection.RemoteIpAddress.ToString(); var result = await _postMasterService.PostNewTopic(user, newPost, ip, userProfileUrl, TopicLinkGenerator, RedirectLinkGenerator); if (result.IsSuccessful) return Json(new BasicJsonMessage {Result = true, Redirect = result.Redirect}); return Json(new BasicJsonMessage {Result = false, Message = result.Message}); } private async Task> GetForumByIdWithPermissionContext(int forumID, User user) { var forum = await _forumService.Get(forumID); if (forum == null) throw new Exception($"Forum {forumID} not found"); var permissionContext = await _forumPermissionService.GetPermissionContext(forum, user); return Tuple.Create(forum, permissionContext); } private async Task> GetPermissionContextByTopicID(int topicID) { var topic = await _topicService.Get(topicID); if (topic == null) throw new Exception($"Topic {topicID} not found"); var forum = await _forumService.Get(topic.ForumID); if (forum == null) throw new Exception($"Forum {topic.ForumID} not found"); var user = _userRetrievalShim.GetUser(); var permissionContext = await _forumPermissionService.GetPermissionContext(forum, user); return Tuple.Create(permissionContext, topic); } public async Task TopicID(int id) { var topic = await _topicService.Get(id); if (topic == null) return NotFound(); return RedirectToActionPermanent("Topic", new { id = topic.UrlName }); } public async Task Topic(string id, int pageNumber = 1) { var topic = await _topicService.Get(id); if (topic == null) return NotFound(); var forum = await _forumService.Get(topic.ForumID); if (forum == null) throw new Exception($"TopicID {topic.TopicID} references ForumID {topic.ForumID}, which does not exist."); var user = _userRetrievalShim.GetUser(); var adapter = new ForumAdapterFactory(forum); var permissionContext = await _forumPermissionService.GetPermissionContext(forum, user, topic); if (!permissionContext.UserCanView) { return NotFound(); } PagerContext pagerContext = null; DateTime? lastReadTime = DateTime.UtcNow; if (user != null) { lastReadTime = await _lastReadService.GetLastReadTime(user, topic); if (!adapter.IsAdapterEnabled || (adapter.IsAdapterEnabled && adapter.ForumAdapter.MarkViewedTopicRead)) await _lastReadService.MarkTopicRead(user, topic); if (user.IsInRole(PermanentRoles.Moderator)) { var categorizedForums = await _forumService.GetCategorizedForumContainer(); var categorizedForumSelectList = new List(); foreach (var uncategorizedForum in categorizedForums.UncategorizedForums) categorizedForumSelectList.Add(new SelectListItem { Value = uncategorizedForum.ForumID.ToString(), Text = uncategorizedForum.Title, Selected = forum.ForumID == uncategorizedForum.ForumID}); foreach (var categoryPair in categorizedForums.CategoryDictionary) { var group = new SelectListGroup {Name = categoryPair.Key.Title}; foreach (var categorizedForum in categoryPair.Value) categorizedForumSelectList.Add(new SelectListItem { Value = categorizedForum.ForumID.ToString(), Text = categorizedForum.Title, Selected = forum.ForumID == categorizedForum.ForumID, Group = group}); } ViewBag.CategorizedForums = categorizedForumSelectList; } } List posts; if (forum.IsQAForum) posts = await _postService.GetPosts(topic, permissionContext.UserCanModerate); else (posts, pagerContext) = await _postService.GetPosts(topic, permissionContext.UserCanModerate, pageNumber); if (posts.Count == 0) return NotFound(); var signatures = await _profileService.GetSignatures(posts); var avatars = await _profileService.GetAvatars(posts); var votedIDs = await _postService.GetVotedPostIDs(user, posts); var ignores = await _ignoreService.GetIgnoredUserIdsInList(user, posts); var container = ComposeTopicContainer(topic, forum, permissionContext, posts, pagerContext, signatures, avatars, votedIDs, lastReadTime, ignores); await _topicViewCountService.ProcessView(topic); await _topicViewLogService.LogView(user?.UserID, topic.TopicID); var topicState = await _topicStateComposer.GetState(topic, pagerContext?.PageIndex, pagerContext?.PageCount, posts.Last().PostID); container.TopicState = topicState; if (adapter.IsAdapterEnabled) { await adapter.ForumAdapter.AdaptTopic(this, container); if (string.IsNullOrWhiteSpace(adapter.ForumAdapter.ViewName)) return View(adapter.ForumAdapter.Model); return View(adapter.ForumAdapter.ViewName, adapter.ForumAdapter.Model); } if (forum.IsQAForum) { var containerForQA = _forumService.MapTopicContainerForQA(container); return View("TopicQA", containerForQA); } return View(container); } public async Task TopicPage(int id, int pageNumber, int low, int high) { var topic = await _topicService.Get(id); if (topic == null) return NotFound(); var forum = await _forumService.Get(topic.ForumID); if (forum == null) throw new Exception($"TopicID {topic.TopicID} references ForumID {topic.ForumID}, which does not exist."); var user = _userRetrievalShim.GetUser(); var permissionContext = await _forumPermissionService.GetPermissionContext(forum, user, topic); if (!permissionContext.UserCanView) { return StatusCode(403); } DateTime? lastReadTime = DateTime.UtcNow; if (user != null) { lastReadTime = await _lastReadService.GetLastReadTime(user, topic); } var (posts, pagerContext) = await _postService.GetPosts(topic, permissionContext.UserCanModerate, pageNumber); if (posts.Count == 0) return NotFound(); var signatures = await _profileService.GetSignatures(posts); var avatars = await _profileService.GetAvatars(posts); var votedIDs = await _postService.GetVotedPostIDs(user, posts); var ignores = await _ignoreService.GetIgnoredUserIdsInList(user, posts); var container = ComposeTopicContainer(topic, forum, permissionContext, posts, pagerContext, signatures, avatars, votedIDs, lastReadTime, ignores); await _topicViewCountService.ProcessView(topic); ViewBag.Low = low; ViewBag.High = high; return View(container); } public async Task PostReply(int id, int replyID = 0) { var user = _userRetrievalShim.GetUser(); if (user == null) return Content(Resources.LoginToPost); var topic = await _topicService.Get(id); if (topic == null) return Content(Resources.TopicNotExist); var forum = await _forumService.Get(topic.ForumID); if (forum == null) throw new Exception($"TopicID {topic.TopicID} references ForumID {topic.ForumID}, which does not exist."); if (topic.IsClosed) return Content(Resources.Closed); var permissionContext = await _forumPermissionService.GetPermissionContext(forum, user, topic); if (!permissionContext.UserCanView) return Content(Resources.ForumNoView); if (!permissionContext.UserCanPost) return Content(Resources.ForumNoPost); var title = topic.Title; if (!title.ToLower().StartsWith("re:")) title = "Re: " + title; var profile = await _profileService.GetProfile(user); var newPost = new NewPost { ItemID = topic.TopicID, Title = title, IncludeSignature = profile.Signature.Length > 0, IsPlainText = profile.IsPlainText, IsImageEnabled = _settingsManager.Current.AllowImages, ParentPostID = replyID }; if (forum.IsQAForum) { newPost.IncludeSignature = false; if (newPost.ParentPostID == 0) { ViewBag.IsQA = true; return View("NewReply", newPost); } return View("NewComment", newPost); } return View("NewReply", newPost); } [HttpPost] public async Task PostReply([FromBody]NewPost newPost) { var user = _userRetrievalShim.GetUser(); var userProfileUrl = Url.Action("ViewProfile", "Account", new { id = user.UserID }); string TopicLinkGenerator(Topic t) => Url.Action("GoToNewestPost", Name, new { id = t.TopicID }); string PostLinkGenerator(Post p) => Url.Action("PostLink", "Forum", new {id = p.PostID}); string RedirectLinkGenerator(Post p) => Url.RouteUrl(new {controller = "Forum", action = "PostLink", id = p.PostID}); var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); var result = await _postMasterService.PostReply(user, newPost.ParentPostID, ip, false, newPost, DateTime.UtcNow, TopicLinkGenerator, userProfileUrl, PostLinkGenerator, RedirectLinkGenerator); return Json(new BasicJsonMessage { Result = result.IsSuccessful, Redirect = result.Redirect, Message = result.Message }); } public async Task Post(int id) { var post = await _postService.Get(id); if (post == null) return NotFound(); var (permissionContext, topic) = await GetPermissionContextByTopicID(post.TopicID); if (!permissionContext.UserCanView) return StatusCode(403); var user = _userRetrievalShim.GetUser(); var postList = new List { post }; var signatures = await _profileService.GetSignatures(postList); var avatars = await _profileService.GetAvatars(postList); var votedPostIDs = await _postService.GetVotedPostIDs(user, postList); ViewData["PopForums.Identity.CurrentUser"] = user; // TODO: what is this used for? if (user != null) await _lastReadService.MarkTopicRead(user, topic); var ignoreUserIDs = await _ignoreService.GetIgnoredUserIdsInList(user, postList); return View("PostItem", new PostItemContainer { Post = post, Avatars = avatars, Signatures = signatures, VotedPostIDs = votedPostIDs, Topic = topic, User = user, IgnoreUserIDs = ignoreUserIDs }); } public async Task Recent(int pageNumber = 1) { var includeDeleted = false; var user = _userRetrievalShim.GetUser(); if (user != null && user.IsInRole(PermanentRoles.Moderator)) includeDeleted = true; var titles = _forumService.GetAllForumTitles(); var (topics, pagerContext) = await _forumService.GetRecentTopics(user, includeDeleted, pageNumber); var container = new PagedTopicContainer { ForumTitles = titles, PagerContext = pagerContext, Topics = topics }; await _lastReadService.GetTopicReadStatus(user, container); var forumState = _forumStateComposer.GetState(null, pagerContext); ViewBag.ForumState = forumState; // TODO: refactor this into the container return View(container); } [HttpPost] public async Task MarkForumRead(int id) { var user = _userRetrievalShim.GetUser(); if (user == null) throw new Exception("There is no logged in user. Can't mark forum read."); var forum = await _forumService.Get(id); if (forum == null) throw new Exception($"There is no ForumID {id} to mark as read."); await _lastReadService.MarkForumRead(user, forum); return RedirectToAction("Index", HomeController.Name); } [HttpPost] public async Task MarkAllForumsRead() { var user = _userRetrievalShim.GetUser(); if (user == null) throw new Exception("There is no logged in user. Can't mark forum read."); await _lastReadService.MarkAllForumsRead(user); return RedirectToAction("Index", HomeController.Name); } public async Task PostLink(int id) { var includeDeleted = false; var user = _userRetrievalShim.GetUser(); if (user != null && user.IsInRole(PermanentRoles.Moderator)) includeDeleted = true; var post = await _postService.Get(id); if (post == null || (post.IsDeleted && (user == null || !user.IsInRole(PermanentRoles.Moderator)))) return NotFound(); var (pageNumber, topic) = await _postService.GetTopicPageForPost(post, includeDeleted); var forum = await _forumService.Get(topic.ForumID); var adapter = new ForumAdapterFactory(forum); if (adapter.IsAdapterEnabled) { var result = await adapter.ForumAdapter.AdaptPostLink(this, post, topic, forum); if (result != null) return result; } var url = Url.Action("Topic", new { id = topic.UrlName, pageNumber }) + "#" + post.PostID; return Redirect(url); } public async Task GoToNewestPost(int id) { var topic = await _topicService.Get(id); if (topic == null) return NotFound(); var includeDeleted = false; var user = _userRetrievalShim.GetUser(); if (user != null && user.IsInRole(PermanentRoles.Moderator)) includeDeleted = true; if (user == null) return RedirectToAction("Topic", new { id = topic.UrlName }); var post = await _lastReadService.GetFirstUnreadPost(user, topic); var (pageNumber, t) = await _postService.GetTopicPageForPost(post, includeDeleted); var url = Url.Action("Topic", new { id = topic.UrlName, pageNumber }) + "#" + post.PostID; return Redirect(url); } public async Task Edit(int id) { var post = await _postService.Get(id); if (post == null) return NotFound(); var user = _userRetrievalShim.GetUser(); if (!user.IsPostEditable(post)) return StatusCode(403); var postEdit = await _postService.GetPostForEdit(post, user); return View(postEdit); } [HttpPost] public async Task Edit(int id, PostEdit postEdit) { var user = _userRetrievalShim.GetUser(); postEdit.PostImageIDs = postEdit.PostImageIDs[0]?.Split(','); string RedirectLinkGenerator(Post p) => Url.RouteUrl(new { controller = "Forum", action = "PostLink", id = p.PostID }); var result = await _postMasterService.EditPost(id, postEdit, user, RedirectLinkGenerator); if (result.IsSuccessful) return Redirect(result.Redirect); ViewBag.Message = result.Message; return View(postEdit); } [HttpPost] public async Task DeletePost(int id) { var post = await _postService.Get(id); var user = _userRetrievalShim.GetUser(); if (!user.IsPostEditable(post)) return StatusCode(403); await _postService.Delete(post, user); if (post.IsFirstInTopic || !user.IsInRole(PermanentRoles.Moderator)) { var topic = await _topicService.Get(post.TopicID); var forum = await _forumService.Get(topic.ForumID); return RedirectToAction("Index", "Forum", new { urlName = forum.UrlName }); } return RedirectToAction("PostLink", "Forum", new { id = post.PostID }); } public async Task IsLastPostInTopic(int id, int lastPostID) { var last = await _postService.GetLastPostID(id); var result = last == lastPostID; return Content(result.ToString()); } // use this only to load an unknown number of new posts when reply is open public async Task TopicPartial(int id, int lastPost, int lowPage) { var topic = await _topicService.Get(id); if (topic == null) return NotFound(); var forum = await _forumService.Get(topic.ForumID); if (forum == null) throw new Exception($"TopicID {topic.TopicID} references ForumID {topic.ForumID}, which does not exist."); var user = _userRetrievalShim.GetUser(); var permissionContext = await _forumPermissionService.GetPermissionContext(forum, user, topic); if (!permissionContext.UserCanView) { return StatusCode(403); } DateTime? lastReadTime = DateTime.UtcNow; if (user != null) { lastReadTime = await _lastReadService.GetLastReadTime(user, topic); } var (posts, pagerContext) = await _postService.GetPosts(topic, lastPost, permissionContext.UserCanModerate); var signatures = await _profileService.GetSignatures(posts); var avatars = await _profileService.GetAvatars(posts); var votedIDs = await _postService.GetVotedPostIDs(user, posts); var ignores = await _ignoreService.GetIgnoredUserIdsInList(user, posts); var container = ComposeTopicContainer(topic, forum, permissionContext, posts, pagerContext, signatures, avatars, votedIDs, lastReadTime, ignores); ViewBag.Low = lowPage; ViewBag.High = pagerContext.PageCount; return View("TopicPage", container); } public async Task Voters(int id) { var post = await _postService.Get(id); if (post == null) return NotFound(); var voters = await _postService.GetVoters(post); return View(voters); } [HttpPost] public async Task ToggleVote(int id) { var post = await _postService.Get(id); if (post == null) return NotFound(); var topic = await _topicService.Get(post.TopicID); if (topic == null) throw new Exception($"Post {post.PostID} appears to be orphaned from a topic."); var user = _userRetrievalShim.GetUser(); if (user == null) return StatusCode(403); var helper = Url; var userProfileUrl = helper.Action("ViewProfile", "Account", new { id = user.UserID }); var topicUrl = helper.Action("PostLink", "Forum", new { id = post.PostID }); var result = await _postService.ToggleVoteReturnCountAndIsVoted(post, user, userProfileUrl, topicUrl, topic.Title); var model = new {votes = result.Item1, isVoted = result.Item2}; return Json(model); } public class PreviewModel { public string FullText { get; set; } public bool IsPlainText { get; set; } } [HttpPost] public ContentResult PreviewText([FromBody] PreviewModel model) { var result = _postService.GenerateParsedTextPreview(model.FullText, model.IsPlainText); return Content(result, "text/html"); } private static TopicContainer ComposeTopicContainer(Topic topic, Forum forum, ForumPermissionContext permissionContext, List posts, PagerContext pagerContext, Dictionary signatures, Dictionary avatars, List votedPostIDs, DateTime? lastreadTime, List ignoreUserIDs) { return new TopicContainer { Forum = forum, Topic = topic, Posts = posts, PagerContext = pagerContext, PermissionContext = permissionContext, Signatures = signatures, Avatars = avatars, VotedPostIDs = votedPostIDs, LastReadTime = lastreadTime, IgnoreUserIDs = ignoreUserIDs }; } public class SetAnswerModel { public int TopicID { get; set; } public int PostID { get; set; } } [HttpPost] public async Task SetAnswer([FromBody] SetAnswerModel model) { var post = await _postService.Get(model.PostID); if (post == null) return NotFound(); var topic = await _topicService.Get(model.TopicID); if (topic == null) return NotFound(); var user = _userRetrievalShim.GetUser(); if (user == null) return StatusCode(403); try { var helper = Url; var userProfileUrl = helper.Action("ViewProfile", "Account", new { id = user.UserID }); var topicUrl = helper.Action("PostLink", "Forum", new { id = post.PostID }); await _topicService.SetAnswer(user, topic, post, userProfileUrl, topicUrl); } catch (SecurityException) // TODO: what is this? { return StatusCode(403); } return new EmptyResult(); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Controllers/HomeController.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Controllers; [Area("Forums")] [TypeFilter(typeof(PopForumsPrivateForumsFilter))] public class HomeController : Controller { public HomeController(IForumService forumService, IUserService userService, IUserSessionService userSessionService, IUserRetrievalShim userRetrievalShim) { _forumService = forumService; _userService = userService; _userSessionService = userSessionService; _userRetrievalShim = userRetrievalShim; } public static string Name = "Home"; private readonly IForumService _forumService; private readonly IUserService _userService; private readonly IUserSessionService _userSessionService; private readonly IUserRetrievalShim _userRetrievalShim; public async Task Index() { ViewBag.OnlineUsers = await _userService.GetUsersOnline(); var sessionCount = await _userSessionService.GetTotalSessionCount(); ViewBag.TotalUsers = sessionCount.ToString("N0"); ViewBag.TopicCount = _forumService.GetAggregateTopicCount().Result.ToString("N0"); ViewBag.PostCount = _forumService.GetAggregatePostCount().Result.ToString("N0"); var registeredUsers = await _userService.GetTotalUsers(); ViewBag.RegisteredUsers = registeredUsers.ToString("N0"); var user = _userRetrievalShim.GetUser(); ViewBag.SitemapUrl = Url.Action("Index", SitemapController.Name, null, Request.Scheme); return View(await _forumService.GetCategorizedForumContainerFilteredForUser(user)); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Controllers/IdentityController.cs ================================================ using System.Security.Authentication; using PopForums.Mvc.Areas.Forums.Authentication; using PopIdentity; using PopIdentity.Providers.Facebook; using PopIdentity.Providers.Google; using PopIdentity.Providers.Microsoft; using PopIdentity.Providers.OAuth2; namespace PopForums.Mvc.Areas.Forums.Controllers; [Area("Forums")] public class IdentityController : Controller { private readonly ILoginLinkFactory _loginLinkFactory; private readonly IStateHashingService _stateHashingService; private readonly ISettingsManager _settingsManager; private readonly IFacebookCallbackProcessor _facebookCallbackProcessor; private readonly IGoogleCallbackProcessor _googleCallbackProcessor; private readonly IMicrosoftCallbackProcessor _microsoftCallbackProcessor; private readonly IOAuth2JwtCallbackProcessor _oAuth2JwtCallbackProcessor; private readonly IExternalUserAssociationManager _externalUserAssociationManager; private readonly IUserService _userService; private readonly IExternalLoginTempService _externalLoginTempService; private readonly IUserRetrievalShim _userRetrievalShim; private readonly ISecurityLogService _securityLogService; private readonly IOAuthOnlyService _oAuthOnlyService; private readonly IConfig _config; public IdentityController(ILoginLinkFactory loginLinkFactory, IStateHashingService stateHashingService, ISettingsManager settingsManager, IFacebookCallbackProcessor facebookCallbackProcessor, IGoogleCallbackProcessor googleCallbackProcessor, IMicrosoftCallbackProcessor microsoftCallbackProcessor, IOAuth2JwtCallbackProcessor oAuth2JwtCallbackProcessor, IExternalUserAssociationManager externalUserAssociationManager, IUserService userService, IExternalLoginTempService externalLoginTempService, IUserRetrievalShim userRetrievalShim, ISecurityLogService securityLogService, IOAuthOnlyService oAuthOnlyService, IConfig config) { _loginLinkFactory = loginLinkFactory; _stateHashingService = stateHashingService; _settingsManager = settingsManager; _facebookCallbackProcessor = facebookCallbackProcessor; _googleCallbackProcessor = googleCallbackProcessor; _microsoftCallbackProcessor = microsoftCallbackProcessor; _oAuth2JwtCallbackProcessor = oAuth2JwtCallbackProcessor; _externalUserAssociationManager = externalUserAssociationManager; _userService = userService; _externalLoginTempService = externalLoginTempService; _userRetrievalShim = userRetrievalShim; _securityLogService = securityLogService; _oAuthOnlyService = oAuthOnlyService; _config = config; } public static string Name = "Identity"; public class Credentials { public string Email { get; set; } public string Password { get; set; } } [PopForumsAuthenticationIgnore] [TypeFilter(typeof(OAuthOnlyForbidAttribute))] [HttpPost] public async Task Login([FromBody] Credentials credentials) { var (result, user) = await _userService.Login(credentials.Email, credentials.Password, HttpContext.Connection.RemoteIpAddress?.ToString()); if (result) { await PerformSignInAsync(user, HttpContext); return Json(new BasicJsonMessage { Result = true }); } return Json(new BasicJsonMessage { Result = false, Message = Resources.LoginBad }); } [HttpGet] public async Task Logout() { string link; if (Request == null || string.IsNullOrWhiteSpace(Request.Headers["Referer"])) link = Url.Action("Index", HomeController.Name); else { link = Request.Headers["Referer"]; if (!link.Contains(Request.Host.Value)) link = Url.Action("Index", HomeController.Name); } var user = _userRetrievalShim.GetUser(); await _userService.Logout(user, HttpContext.Connection.RemoteIpAddress?.ToString()); await HttpContext.SignOutAsync(PopForumsAuthenticationDefaults.AuthenticationScheme); return Redirect(link); } [HttpPost] public async Task LogoutAsync() { var user = _userRetrievalShim.GetUser(); await _userService.Logout(user, HttpContext.Connection.RemoteIpAddress?.ToString()); await HttpContext.SignOutAsync(PopForumsAuthenticationDefaults.AuthenticationScheme); return Json(new BasicJsonMessage { Result = true }); } [PopForumsAuthenticationIgnore] [TypeFilter(typeof(OAuthOnlyForbidAttribute))] [HttpPost] public IActionResult ExternalLogin(string provider, string returnUrl) { var state = _stateHashingService.SetCookieAndReturnHash(); string redirect; ProviderType providerType; switch (provider.ToLower()) { case "facebook": var facebookRedirect = Url.Action(nameof(CallbackHandler), Name, null, Request.Scheme); redirect = _loginLinkFactory.GetLink(ProviderType.Facebook, facebookRedirect, state, _settingsManager.Current.FacebookAppID); providerType = ProviderType.Facebook; break; case "google": var googleRedirect = Url.Action(nameof(CallbackHandler), Name, null, Request.Scheme); redirect = _loginLinkFactory.GetLink(ProviderType.Google, googleRedirect, state, _settingsManager.Current.GoogleClientId); providerType = ProviderType.Google; break; case "microsoft": var msftRedirect = Url.Action(nameof(CallbackHandler), Name, null, Request.Scheme); redirect = _loginLinkFactory.GetLink(ProviderType.Microsoft, msftRedirect, state, _settingsManager.Current.MicrosoftClientID); providerType = ProviderType.Microsoft; break; case "oauth2": var oauthRedirect = Url.Action(nameof(CallbackHandler), Name, null, Request.Scheme); var linkGenerator = new OAuth2LoginUrlGenerator(); var oauthClaims = new List(new[] { "openid", "email" }); redirect = linkGenerator.GetUrl(_settingsManager.Current.OAuth2LoginUrl, _settingsManager.Current.OAuth2ClientID, oauthRedirect, state, oauthClaims); providerType = ProviderType.OAuth2; break; default: throw new NotImplementedException($"The external login \"{provider}\" is not configured."); } var loginState = new ExternalLoginState {ProviderType = providerType, ReturnUrl = returnUrl }; _externalLoginTempService.Persist(loginState); return Redirect(redirect); } [PopForumsAuthenticationIgnore] public async Task ExternalLoginCallback(string returnUrl = null) { var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); var loginState = _externalLoginTempService.Read(); if (loginState == null) { await _securityLogService.CreateLogEntry((User)null, null, ip, "Temp auth cookie missing on callback", SecurityLogType.ExternalAssociationCheckFailed); return View("ExternalError", Resources.LoginBad); } var externalLoginInfo = new ExternalLoginInfo(loginState.ProviderType.ToString(), loginState.ResultData.ID, loginState.ResultData.Name); var matchResult = await _externalUserAssociationManager.ExternalUserAssociationCheck(externalLoginInfo, ip); if (matchResult.Successful) { await _userService.Login(matchResult.User, ip); _externalLoginTempService.Remove(); if (loginState.ProviderType == ProviderType.OAuthOnly) await PerformSignInAsync(matchResult.User, HttpContext, DateTime.UtcNow.AddMinutes(_config.OAuthRefreshExpirationMinutes)); else await PerformSignInAsync(matchResult.User, HttpContext); if (!Url.IsLocalUrl(returnUrl)) returnUrl = Url.Action(nameof(HomeController.Index), HomeController.Name); return Redirect(returnUrl); } if (loginState.ProviderType == ProviderType.OAuthOnly) throw new AuthenticationException( "A callback was made to login in IsOAuthOnly mode, but no user was found."); ViewBag.Referrer = returnUrl; return View(); } [PopForumsAuthenticationIgnore] [TypeFilter(typeof(OAuthOnlyForbidAttribute))] [HttpPost] public async Task LoginAndAssociate([FromBody] Credentials credentials) { if (_config.IsOAuthOnly) return Forbid(); var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); var (result, user) = await _userService.Login(credentials.Email, credentials.Password, ip); if (result) { var loginState = _externalLoginTempService.Read(); if (loginState != null) { var externalLoginInfo = new ExternalLoginInfo(loginState.ProviderType.ToString(), loginState.ResultData.ID, loginState.ResultData.Name); await _externalUserAssociationManager.Associate(user, externalLoginInfo, ip); _externalLoginTempService.Remove(); await PerformSignInAsync(user, HttpContext); return Json(new BasicJsonMessage { Result = true }); } else { await _securityLogService.CreateLogEntry((User)null, null, ip, "Temp auth cookie missing on association", SecurityLogType.ExternalAssociationCheckFailed); return Json(new BasicJsonMessage { Result = false, Message = Resources.Error + ": " + Resources.LoginBad }); } } return Json(new BasicJsonMessage { Result = false, Message = Resources.LoginBad }); } public static async Task PerformSignInAsync(User user, HttpContext httpContext) { var expiration = DateTime.UtcNow.AddYears(1); await PerformSignInAsync(user, httpContext, expiration); } public static async Task PerformSignInAsync(User user, HttpContext httpContext, DateTime expiration) { var claims = new List { new (ClaimTypes.Name, user.Name) }; var props = new AuthenticationProperties { IsPersistent = true, ExpiresUtc = expiration }; var id = new ClaimsIdentity(claims, PopForumsAuthenticationDefaults.AuthenticationScheme); await httpContext.SignInAsync(PopForumsAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(id), props); } [PopForumsAuthenticationIgnore] public async Task CallbackHandler() { var loginState = _externalLoginTempService.Read(); var ip = HttpContext.Connection.RemoteIpAddress?.ToString(); if (loginState == null) { await _securityLogService.CreateLogEntry((User)null, null, ip, "Temp auth cookie missing on callback", SecurityLogType.ExternalAssociationCheckFailed); return View("ExternalError", Resources.Error + ": " + Resources.LoginBad); } var redirectUri = Url.Action(nameof(CallbackHandler), Name, null, Request.Scheme); CallbackResult result; switch (loginState.ProviderType) { case ProviderType.OAuthOnly: result = await _oAuthOnlyService.ProcessOAuthLogin(redirectUri, ip); if (result.IsSuccessful) { loginState.Expiration = result.Token.ValidTo; loginState.ReturnUrl = null; } break; case ProviderType.Facebook: result = await _facebookCallbackProcessor.VerifyCallback(redirectUri, _settingsManager.Current.FacebookAppID, _settingsManager.Current.FacebookAppSecret); break; case ProviderType.Google: result = await _googleCallbackProcessor.VerifyCallback(redirectUri, _settingsManager.Current.GoogleClientId, _settingsManager.Current.GoogleClientSecret); break; case ProviderType.Microsoft: result = await _microsoftCallbackProcessor.VerifyCallback(redirectUri, _settingsManager.Current.MicrosoftClientID, _settingsManager.Current.MicrosoftClientSecret); break; case ProviderType.OAuth2: result = await _oAuth2JwtCallbackProcessor.VerifyCallback(redirectUri, _settingsManager.Current.OAuth2TokenUrl, _settingsManager.Current.OAuth2ClientID, _settingsManager.Current.OAuth2ClientSecret); break; default: throw new Exception($"The external login type {loginState.ProviderType} has no callback handler."); } if (!result.IsSuccessful) { return View("ExternalError", result.Message); } loginState.ResultData = result.ResultData; _externalLoginTempService.Persist(loginState); return RedirectToAction("ExternalLoginCallback", new { returnUrl = loginState.ReturnUrl }); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Controllers/IgnoreController.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Controllers; [Area("Forums")] public class IgnoreController(IIgnoreService ignoreService, IUserRetrievalShim userRetrievalShim) : Controller { public static readonly string Name = "Ignore"; [HttpPost] public async Task Add(int userID, int postID) { var user = userRetrievalShim.GetUser(); if (user == null) return Forbid(); await ignoreService.AddIgnore(user.UserID, userID); return RedirectToAction("PostLink", "Forum", new { id = postID }); } [HttpPost] public async Task Remove(int userID, int postID) { var user = userRetrievalShim.GetUser(); if (user == null) return Forbid(); await ignoreService.DeleteIgnore(user.UserID, userID); return RedirectToAction("PostLink", "Forum", new { id = postID }); } [HttpPost] public async Task RemoveFromList(int id) { var user = userRetrievalShim.GetUser(); if (user == null) return Forbid(); await ignoreService.DeleteIgnore(user.UserID, id); return RedirectToAction("List"); } public async Task List() { var user = userRetrievalShim.GetUser(); if (user == null) return View(null); var list = await ignoreService.GetIgnoreList(user.UserID); return View(list); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Controllers/ImageController.cs ================================================ using System.Net.Mime; using PopForums.Mvc.Areas.Forums.Authentication; namespace PopForums.Mvc.Areas.Forums.Controllers; [Area("Forums")] [TypeFilter(typeof(PopForumsPrivateForumsFilter))] public class ImageController : Controller { public ImageController(IImageService imageService, IUserRetrievalShim userRetrievalShim, IPostImageService postImageService, ISettingsManager settingsManager) { _imageService = imageService; _userRetrievalShim = userRetrievalShim; _postImageService = postImageService; _settingsManager = settingsManager; } private readonly IImageService _imageService; private readonly IUserRetrievalShim _userRetrievalShim; private readonly IPostImageService _postImageService; private readonly ISettingsManager _settingsManager; [PopForumsAuthenticationIgnore] public async Task Avatar(int id) { return await SetupImageResult(_imageService.GetAvatarImageStream, _imageService.GetAvatarImageLastModification, id); } [PopForumsAuthenticationIgnore] public async Task UserImage(int id) { return await SetupImageResult(_imageService.GetUserImageStream, _imageService.GetUserImageLastModifcation, id); } private async Task SetupImageResult(Func> imageStreamFetch, Func> imageLastMod, int id) { var timeStamp = await imageLastMod(id); if (!timeStamp.HasValue) return NotFound(); Response.Headers["Cache-control"] = "private"; Response.Headers["Last-modified"] = DateTime.SpecifyKind(timeStamp.Value, DateTimeKind.Utc).ToString("R"); if (!string.IsNullOrEmpty(Request.Headers["If-Modified-Since"])) { var provider = CultureInfo.InvariantCulture; var couldParse = DateTime.TryParseExact(Request.Headers["If-Modified-Since"], "r", provider, DateTimeStyles.None, out var lastMod); if (couldParse && lastMod == timeStamp.Value.AddMilliseconds(-timeStamp.Value.Millisecond)) { Response.StatusCode = 304; return Content(string.Empty); } } var streamResponse = await imageStreamFetch(id); if (streamResponse == null) return NotFound(); Response.RegisterForDispose(streamResponse); return File(streamResponse.Stream, "image/jpeg"); } public async Task PostImage(string id) { var postImageSansData = await _postImageService.GetWithoutData(id); if (postImageSansData == null) return NotFound(); Response.Headers["Cache-control"] = "private"; Response.Headers["Last-modified"] = DateTime.SpecifyKind(postImageSansData.TimeStamp, DateTimeKind.Utc).ToString("R"); if (!string.IsNullOrEmpty(Request.Headers["If-Modified-Since"])) { var provider = CultureInfo.InvariantCulture; var couldParse = DateTime.TryParseExact(Request.Headers["If-Modified-Since"], "r", provider, DateTimeStyles.None, out var lastMod); if (couldParse && lastMod == postImageSansData.TimeStamp.AddMilliseconds(-postImageSansData.TimeStamp.Millisecond)) { Response.StatusCode = 304; return Content(string.Empty); } } var streamResponse = await _postImageService.GetImageStream(id); if (streamResponse == null) return NotFound(); Response.RegisterForDispose(streamResponse); return File(streamResponse.Stream, postImageSansData.ContentType); } [HttpPost] public async Task UploadPostImage() { var user = _userRetrievalShim.GetUser(); if (user == null) return Unauthorized(); if (!_settingsManager.Current.AllowImages) return BadRequest(); var file = Request.Form.Files[0]; if (file.ContentType != MediaTypeNames.Image.Jpeg && file.ContentType != MediaTypeNames.Image.Gif && file.ContentType != "image/png") return BadRequest(); var stream = file.OpenReadStream(); var bytes = stream.ToBytes(); var isOk = _postImageService.ProcessImageIsOk(bytes, file.ContentType); if (!isOk) return BadRequest(); var url = await _postImageService.PersistAndGetPayload(); return Ok(url); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Controllers/ModeratorController.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Controllers; [Authorize(Policy = PermanentRoles.Moderator)] [Area("Forums")] [TypeFilter(typeof(PopForumsPrivateForumsFilter))] public class ModeratorController : Controller { public ModeratorController(ITopicService topicService, IForumService forumService, IPostService postService, IModerationLogService moderationLogService, IUserRetrievalShim userRetrievalShim) { _topicService = topicService; _forumService = forumService; _postService = postService; _moderationLogService = moderationLogService; _userRetrievalShim = userRetrievalShim; } private readonly ITopicService _topicService; private readonly IForumService _forumService; private readonly IPostService _postService; private readonly IModerationLogService _moderationLogService; private readonly IUserRetrievalShim _userRetrievalShim; [HttpPost] public async Task TogglePin(int id) { var topic = await _topicService.Get(id); if (topic == null) throw new Exception($"Topic with ID {id} not found. Can't pin/unpin."); var user = _userRetrievalShim.GetUser(); if (topic.IsPinned) await _topicService.UnpinTopic(topic, user); else await _topicService.PinTopic(topic, user); return RedirectToAction("Topic", "Forum", new { id = topic.UrlName }); } [HttpPost] public async Task ToggleClosed(int id) { var topic = await _topicService.Get(id); if (topic == null) throw new Exception($"Topic with ID {id} not found. Can't open/close."); var user = _userRetrievalShim.GetUser(); if (topic.IsClosed) await _topicService.OpenTopic(topic, user); else await _topicService.CloseTopic(topic, user); return RedirectToAction("Topic", "Forum", new { id = topic.UrlName }); } [HttpPost] public async Task ToggleDeleted(int id) { var topic = await _topicService.Get(id); if (topic == null) throw new Exception($"Topic with ID {id} not found. Can't delete/undelete."); var user = _userRetrievalShim.GetUser(); if (topic.IsDeleted) await _topicService.UndeleteTopic(topic, user); else await _topicService.DeleteTopic(topic, user); return RedirectToAction("Topic", "Forum", new { id = topic.UrlName }); } [HttpPost] public async Task UpdateTopic(IFormCollection collection) { int topicID; if (!int.TryParse(collection["TopicID"], out topicID)) throw new Exception("Parse TopicID fail."); var topic = await _topicService.Get(topicID); if (topic == null) throw new Exception($"Topic with ID {topicID} not found. Can't update."); var user = _userRetrievalShim.GetUser(); var newTitle = collection["NewTitle"]; int forumID; if (!int.TryParse(collection["NewForum"], out forumID)) throw new Exception("Parse ForumID fail."); var forum = await _forumService.Get(forumID); if (forum == null) throw new Exception($"Forum with ID {forumID} not found. Can't update."); await _topicService.UpdateTitleAndForum(topic, forum, newTitle, user); return RedirectToAction("Topic", "Forum", new { id = topic.UrlName }); } [HttpPost] public async Task UndeletePost(int id) { var post = await _postService.Get(id); if (post == null) throw new Exception($"Post with ID {id} not found. Can't undelete."); var user = _userRetrievalShim.GetUser(); await _postService.Undelete(post, user); return RedirectToAction("PostLink", "Forum", new { id = post.PostID }); } public async Task TopicModerationLog(int id) { var topic = await _topicService.Get(id); if (topic == null) throw new Exception($"There is no topic with ID {id} to obtain a moderation log for."); var log = await _moderationLogService.GetLog(topic, true); return View(log); } public async Task PostModerationLog(int id) { var post = await _postService.Get(id); if (post == null) throw new Exception($"There is no post with ID {id} to obtain a moderation log for."); var log = await _moderationLogService.GetLog(post); return View(log); } [HttpPost] public async Task DeleteTopicPermanently(int id) { var topic = await _topicService.Get(id); if (topic == null) throw new Exception($"Topic with ID {id} not found. Can't undelete."); var user = _userRetrievalShim.GetUser(); var forum = await _forumService.Get(topic.ForumID); await _topicService.HardDeleteTopic(topic, user); return RedirectToAction("Index", "Forum", new { urlName = forum.UrlName }); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Controllers/PrivateMessagesController.cs ================================================ using PopForums.Composers; namespace PopForums.Mvc.Areas.Forums.Controllers; [Area("Forums")] [TypeFilter(typeof(PopForumsPrivateForumsFilter))] public class PrivateMessagesController : Controller { public PrivateMessagesController(IPrivateMessageService privateMessageService, IUserService userService, IUserRetrievalShim userRetrievalShim, IPrivateMessageStateComposer privateMessageStateComposer) { _privateMessageService = privateMessageService; _userService = userService; _userRetrievalShim = userRetrievalShim; _privateMessageStateComposer = privateMessageStateComposer; } private readonly IPrivateMessageService _privateMessageService; private readonly IUserService _userService; private readonly IUserRetrievalShim _userRetrievalShim; private readonly IPrivateMessageStateComposer _privateMessageStateComposer; public static string Name = "PrivateMessages"; public async Task Index(int pageNumber = 1) { var user = _userRetrievalShim.GetUser(); if (user == null) return StatusCode(403); var (privateMessages, pagerContext) = await _privateMessageService.GetPrivateMessages(user, PrivateMessageBoxType.Inbox, pageNumber); ViewBag.PagerContext = pagerContext; return View(privateMessages); } public async Task Archive(int pageNumber = 1) { var user = _userRetrievalShim.GetUser(); if (user == null) return StatusCode(403); var (privateMessages, pagerContext) = await _privateMessageService.GetPrivateMessages(user, PrivateMessageBoxType.Archive, pageNumber); ViewBag.PagerContext = pagerContext; return View(privateMessages); } public async Task View(int id) { var user = _userRetrievalShim.GetUser(); if (user == null) return StatusCode(403); var pm = await _privateMessageService.Get(id, user.UserID); if (await _privateMessageService.IsUserInPM(user.UserID, id) == false) return StatusCode(403); var state = await _privateMessageStateComposer.GetState(pm); var model = new PrivateMessageView { PrivateMessage = pm, State = state }; await _privateMessageService.MarkPMRead(user.UserID, id); return View(model); } public async Task Create(int? id) { var user = _userRetrievalShim.GetUser(); if (user == null) return StatusCode(403); ViewBag.UserIDs = " "; if (id.HasValue && id != user.UserID) { var targetUser = await _userService.GetUser(id.Value); ViewBag.UserIDs = targetUser.UserID.ToString(CultureInfo.InvariantCulture); ViewBag.UserID = targetUser.UserID.ToString(CultureInfo.InvariantCulture); ViewBag.TargetUserID = targetUser.UserID; ViewBag.TargetUserName = targetUser.Name; } return View(); } [HttpPost] public async Task Create(string fullText, string userIDs) { var user = _userRetrievalShim.GetUser(); if (user == null) return StatusCode(403); if (string.IsNullOrWhiteSpace(userIDs) || string.IsNullOrWhiteSpace(fullText)) { ViewBag.Warning = Resources.PMCreateWarnings; return View("Create"); } var ids = userIDs.Split(new[] { ',' }).Select(i => Convert.ToInt32(i)); if (ids.Count() > 10) ids = ids.Take(10); var users = ids.Select(id => _userService.GetUser(id).Result).ToList(); var pm = await _privateMessageService.Create(fullText, user, users); return RedirectToAction("View", new { id = pm.PMID }); } public async Task GetNames(string id) { var users = await _userService.SearchByName(id); var projection = users.Select(u => new { u.UserID, value = u.Name }); return Json(projection); } [HttpPost] public async Task ArchivePM(int id) { var user = _userRetrievalShim.GetUser(); if (user == null) return StatusCode(403); var pm = await _privateMessageService.Get(id, user.UserID); if (await _privateMessageService.IsUserInPM(user.UserID, pm.PMID) == false) return StatusCode(403); await _privateMessageService.Archive(user, pm); return RedirectToAction("Index"); } [HttpPost] public async Task UnarchivePM(int id) { var user = _userRetrievalShim.GetUser(); if (user == null) return StatusCode(403); var pm = await _privateMessageService.Get(id, user.UserID); if (await _privateMessageService.IsUserInPM(user.UserID, pm.PMID) == false) return StatusCode(403); await _privateMessageService.Unarchive(user, pm); return RedirectToAction("Archive"); } public async Task NewPMCount() { var user = _userRetrievalShim.GetUser(); if (user == null) return Content(String.Empty); var count = await _privateMessageService.GetUnreadCount(user.UserID); return Content(count.ToString()); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Controllers/ResourcesController.cs ================================================ using PopForums.Composers; namespace PopForums.Mvc.Areas.Forums.Controllers; [Area("Forums")] public class ResourcesController : Controller { private readonly IResourceComposer _resourceComposer; public ResourcesController(IResourceComposer resourceComposer) { _resourceComposer = resourceComposer; } [ResponseCache(Duration = 600, Location = ResponseCacheLocation.Client)] public JsonResult Index() { var result = _resourceComposer.GetForCurrentThread(); return Json(result); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Controllers/SearchController.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Controllers; [Area("Forums")] [TypeFilter(typeof(PopForumsPrivateForumsFilter))] public class SearchController : Controller { public SearchController(ISearchService searchService, IForumService forumService, ILastReadService lastReadService, IUserRetrievalShim userRetrievalShim) { _searchService = searchService; _forumService = forumService; _lastReadService = lastReadService; _userRetrievalShim = userRetrievalShim; } private readonly ISearchService _searchService; private readonly IForumService _forumService; private readonly ILastReadService _lastReadService; private readonly IUserRetrievalShim _userRetrievalShim; public static string Name = "Search"; public ViewResult Index() { var container = new PagedTopicContainer { PagerContext = new PagerContext { PageCount = 0, PageIndex = 1 }, Topics = new List() }; ViewBag.SearchTypes = new SelectList(Enum.GetValues(typeof(SearchType))); return View(container); } [HttpPost] public ActionResult Process(IFormCollection collection) { var query = Request.Form["Query"]; var searchType = Request.Form["SearchType"]; return RedirectToAction("Result", new { query, searchType }); } public async Task Result(string query, SearchType searchType = SearchType.Rank, int pageNumber = 1) { ViewBag.SearchTypes = new SelectList(Enum.GetValues(typeof(SearchType))); ViewBag.Query = query; ViewBag.SearchType = searchType; var includeDeleted = false; var user = _userRetrievalShim.GetUser(); if (user != null && user.IsInRole(PermanentRoles.Moderator)) includeDeleted = true; var titles = _forumService.GetAllForumTitles(); var (topics, pagerContext) = await _searchService.GetTopics(query, searchType, user, includeDeleted, pageNumber); var container = new PagedTopicContainer { ForumTitles = titles, PagerContext = pagerContext, Topics = topics.Data }; ViewBag.IsError = !topics.IsValid; if (topics.IsValid) await _lastReadService.GetTopicReadStatus(user, container); return View("Index", container); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Controllers/SetupController.cs ================================================ using PopForums.Mvc.Areas.Forums.Authentication; namespace PopForums.Mvc.Areas.Forums.Controllers; [Area("Forums")] public class SetupController : Controller { public SetupController(ISetupService setupService, IConfig config) { _setupService = setupService; _config = config; } private readonly ISetupService _setupService; private readonly IConfig _config; public static string Name = "Setup"; [PopForumsAuthenticationIgnore] public ActionResult Index() { if (_config.IsOAuthOnly) return RedirectToAction(nameof(OAuthOnlySetup)); if (!_setupService.IsConnectionPossible()) return View("NoConnection"); if (_setupService.IsDatabaseSetup()) return StatusCode(403); var setupVariables = new SetupVariables { SmtpPort = 25 }; return View(setupVariables); } public IActionResult OAuthOnlySetup() { if (!_setupService.IsConnectionPossible()) return View("NoConnection"); if (_setupService.IsDatabaseSetup()) return StatusCode(403); var exception = _setupService.SetupDatabaseWithoutSettingsOrUser(); if (exception != null) return View("Exception"); return View("Success"); } [PopForumsAuthenticationIgnore] [HttpPost] public async Task Index(SetupVariables setupVariables) { if (_setupService.IsDatabaseSetup()) return StatusCode(403); var result = await _setupService.SetupDatabase(setupVariables); if (result.Item2 != null) return View("Exception", result.Item2); // can't login here because all of the normal app startup was skipped //await AuthorizationController.PerformSignInAsync(true, user, HttpContext); return View("Success"); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Controllers/SitemapController.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Controllers; [Area("Forums")] [TypeFilter(typeof(PopForumsPrivateForumsFilter))] public class SitemapController : Controller { private readonly ISitemapService _sitemapService; public static string Name = "Sitemap"; public SitemapController(ISitemapService sitemapService) { _sitemapService = sitemapService; } [HttpGet("/Forums/Sitemap.xml")] [ResponseCache(Duration = 900)] public async Task Index() { string SitemapPageLinkGenerator(int page) => Url.Action("Page", Name, new { page }, Request.Scheme); var sitemapIndex = await _sitemapService.GenerateIndex(SitemapPageLinkGenerator); return Content(sitemapIndex, "text/xml"); } [HttpGet("/Forums/Sitemap.{page}.xml")] [ResponseCache(Duration = 900)] public async Task Page(int page) { string TopicLinkGenerator(string id) => Url.Action("Topic", ForumController.Name, new { id }, Request.Scheme); var sitemap = await _sitemapService.GeneratePage(TopicLinkGenerator, page); return Content(sitemap, "text/xml"); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Controllers/SubscriptionController.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Controllers; [Area("Forums")] [TypeFilter(typeof(PopForumsPrivateForumsFilter))] public class SubscriptionController : Controller { public SubscriptionController(ISubscribedTopicsService subService, ITopicService topicService, IUserService userService, ILastReadService lastReadService, IForumService forumService, IUserRetrievalShim userRetrievalShim, IProfileService profileService) { _subService = subService; _topicService = topicService; _userService = userService; _lastReadService = lastReadService; _forumService = forumService; _userRetrievalShim = userRetrievalShim; _profileService = profileService; } public static string Name = "Subscription"; private readonly ISubscribedTopicsService _subService; private readonly ITopicService _topicService; private readonly IUserService _userService; private readonly ILastReadService _lastReadService; private readonly IForumService _forumService; private readonly IUserRetrievalShim _userRetrievalShim; private readonly IProfileService _profileService; public async Task Topics(int pageNumber = 1) { var user = _userRetrievalShim.GetUser(); if (user == null) return View(); var (topics, pagerContext) = await _subService.GetTopics(user, pageNumber); var titles = _forumService.GetAllForumTitles(); var container = new PagedTopicContainer { PagerContext = pagerContext, Topics = topics, ForumTitles = titles }; await _lastReadService.GetTopicReadStatus(user, container); return View(container); } [HttpPost] public async Task Unsubscribe(int id) { var user = _userRetrievalShim.GetUser(); var topic = await _topicService.Get(id); await _subService.TryRemoveSubscribedTopic(user, topic); return RedirectToAction("Topics"); } [HttpPost] public async Task ToggleSubscription(int id) { var user = _userRetrievalShim.GetUser(); if (user == null) return Json(new BasicJsonMessage { Message = Resources.LoginToPost, Result = false }); var topic = await _topicService.Get(id); if (topic == null) return Json(new BasicJsonMessage { Message = Resources.TopicNotExist, Result = false }); if (await _subService.IsTopicSubscribed(user.UserID, topic.TopicID)) { await _subService.RemoveSubscribedTopic(user, topic); return Json(new BasicJsonMessage { Data = new { isSubscribed = false }, Result = true }); } await _subService.AddSubscribedTopic(user.UserID, topic.TopicID); return Json(new BasicJsonMessage { Data = new { isSubscribed = true }, Result = true }); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Extensions/ApplicationBuilders.cs ================================================ using PopForums.Mvc.Areas.Forums.Authentication; namespace PopForums.Mvc.Areas.Forums.Extensions; public static class ApplicationBuilders { /// /// Enables the POP Forums middleware to identify PF users. /// public static IApplicationBuilder UsePopForumsAuth(this IApplicationBuilder app) { app.UseMiddleware(); return app; } /// /// Enables the localization (languages) for POP Forums. Call this before UseMvc. /// public static IApplicationBuilder UsePopForumsCultures(this IApplicationBuilder app) { var supportedCultures = new List { new CultureInfo("en"), new CultureInfo("de"), new CultureInfo("es"), new CultureInfo("nl"), new CultureInfo("uk"), new CultureInfo("zh-TW") }; app.UseRequestLocalization(new RequestLocalizationOptions { DefaultRequestCulture = new RequestCulture("en", "en"), SupportedCultures = supportedCultures, SupportedUICultures = supportedCultures }); return app; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Extensions/AuthorizationOptionsExtensions.cs ================================================ using PopForums.Mvc.Areas.Forums.Authentication; namespace PopForums.Mvc.Areas.Forums.Extensions; public static class AuthorizationOptionsExtensions { /// /// Adds authorization options to require certain claims for moderator and admin roles in POP Forums. /// /// public static void AddPopForumsPolicies(this AuthorizationOptions options) { options.AddPolicy(PermanentRoles.Admin, policy => policy.RequireClaim(PopForumsAuthenticationDefaults.ForumsClaimType, PermanentRoles.Admin)); options.AddPolicy(PermanentRoles.Moderator, policy => policy.RequireClaim(PopForumsAuthenticationDefaults.ForumsClaimType, PermanentRoles.Moderator)); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Extensions/Logger.cs ================================================ using PopForums.Mvc.Areas.Forums.Authentication; namespace PopForums.Mvc.Areas.Forums.Extensions; public class Logger : ILogger { private readonly IErrorLog _errorLog; private readonly ISettingsManager _settingsManager; private readonly IHttpContextAccessor _httpContextAccessor; public Logger(IErrorLog errorLog, ISettingsManager settingsManager, IHttpContextAccessor httpContextAccessor) { _errorLog = errorLog; _settingsManager = settingsManager; _httpContextAccessor = httpContextAccessor; } public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) { if (exception is ErrorLogException) return; if (IsEnabled(logLevel)) { string data = null; if (_httpContextAccessor?.HttpContext == null) data = "HttpContext not available"; else { var context = _httpContextAccessor.HttpContext; if (context != null) { var s = new StringBuilder(); var user = context.User.Identities.SingleOrDefault( x => x.AuthenticationType == PopForumsAuthenticationDefaults.AuthenticationScheme); if (user != null) { s.Append("User: "); s.Append(user.Name); s.Append("\r\n"); } s.Append("IP: "); s.Append(context.Connection.RemoteIpAddress); s.Append("\r\n\r\n"); foreach (var item in context.Request.Headers) { s.Append(item.Key); s.Append(": "); s.Append(item.Value); s.Append("\r\n"); } data = s.ToString(); } } _errorLog.Log(exception, ErrorSeverity.Error, data); } } public bool IsEnabled(LogLevel logLevel) { return logLevel == LogLevel.Error && _settingsManager.Current.LogErrors; } public IDisposable BeginScope(TState state) { return NoopDisposable.Instance; } private class NoopDisposable : IDisposable { public static NoopDisposable Instance = new NoopDisposable(); public void Dispose() { } } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Extensions/LoggerFactories.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Extensions; public static class LoggerFactories { public static ILoggerFactory AddPopForumsLogger(this ILoggerFactory logger, IApplicationBuilder app) { var setupService = app.ApplicationServices.GetService(); if (!setupService.IsConnectionPossible() || !setupService.IsDatabaseSetup()) return logger; var errorLog = app.ApplicationServices.GetService(); var settingsManager = app.ApplicationServices.GetService(); var contextAccessor = app.ApplicationServices.GetService(); logger.AddProvider(new LoggerProvider(errorLog, settingsManager, contextAccessor)); return logger; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Extensions/LoggerProvider.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Extensions; public class LoggerProvider : ILoggerProvider { private readonly IErrorLog _errorLog; private readonly ISettingsManager _settingsManager; private readonly IHttpContextAccessor _httpContextAccessor; public LoggerProvider(IErrorLog errorLog, ISettingsManager settingsManager, IHttpContextAccessor httpContextAccessor) { _errorLog = errorLog; _settingsManager = settingsManager; _httpContextAccessor = httpContextAccessor; } public void Dispose() { } public ILogger CreateLogger(string categoryName) { return new Logger(_errorLog, _settingsManager, _httpContextAccessor); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Extensions/ServiceCollections.cs ================================================ using PopForums.Mvc.Areas.Forums.Authentication; using PopForums.Mvc.Areas.Forums.BackgroundJobs; using PopIdentity.Extensions; namespace PopForums.Mvc.Areas.Forums.Extensions; public static class ServiceCollections { /// /// Adds web project services to dependency injection container and authentication for POP Forums. This method /// fails if the ISetupService can't connect to the database or the database isn't set up. /// /// /// The updated IServiceCollection. public static IServiceCollection AddPopForumsMvc(this IServiceCollection services) { return services.AddPopForumsMvc(true); } /// /// Adds web project services to dependency injection container and authentication for POP Forums. This method /// fails if the ISetupService can't connect to the database or the database isn't set up. /// /// /// Indicate false if you intend to call /// services.AddPopForumsBase() on your own. /// The updated IServiceCollection. public static IServiceCollection AddPopForumsMvc(this IServiceCollection services, bool includePopForumsBaseServices) { if (includePopForumsBaseServices) services.AddPopForumsBase(); services.AddHttpContextAccessor(); services.AddPopIdentity(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); var serviceProvider = services.BuildServiceProvider(); var setupService = serviceProvider.GetService(); if (!setupService.IsConnectionPossible() || !setupService.IsDatabaseSetup()) return services; services.AddAuthentication() .AddCookie(PopForumsAuthenticationDefaults.AuthenticationScheme, option => { option.ExpireTimeSpan = new TimeSpan(365, 0, 0, 0); option.LoginPath = "/Forums/Account/Login"; // TODO: This is lame because of fx, see: https://github.com/dotnet/aspnetcore/issues/9039 option.Events.OnRedirectToAccessDenied = context => { context.Response.StatusCode = 403; return Task.CompletedTask; }; }); return services; } public static IServiceCollection AddPopForumsBackgroundJobs(this IServiceCollection services) { services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); return services; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Extensions/WebApplications.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Extensions; public static class WebApplications { /// /// Adds the POP Forums app to the application. /// /// /// public static WebApplication AddPopForumsEndpoints(this WebApplication app) { app.MapHub("/PopForumsHub", options => { options.Transports = Microsoft.AspNetCore.Http.Connections.HttpTransportType.WebSockets; }); app.MapControllerRoute( "pfadmin", "Forums/Admin/{**vue}", new { controller = AdminController.Name, action = "App", Area = "Forums" }); app.MapControllerRoute( "pfadmin2", "Forums/Admin/{vue}/{**admin}", new { controller = AdminController.Name, action = "App", Area = "Forums" }); app.MapControllerRoute( "pfsetup", "Forums/Setup", new { controller = "Setup", action = "Index", Area = "Forums" } ); var setupService = app.Services.GetService(); if (!setupService.IsConnectionPossible() || !setupService.IsDatabaseSetup()) return app; app.MapControllerRoute( "pfrecent1", "Forums/Recent", new { controller = ForumController.Name, action = "Recent", pageNumber = 1, Area = "Forums" } ); app.MapControllerRoute( "pfrecent", "Forums/Recent/{pageNumber}", new { controller = ForumController.Name, action = "Recent", Area = "Forums" } ); var forumRepository = app.Services.GetService(); var forumConstraint = new ForumRouteConstraint(forumRepository); app.MapControllerRoute( "pfroot1", "Forums/{urlName}", new { controller = ForumController.Name, action = "Index", pageNumber = 1, Area = "Forums" }, new { forum = forumConstraint } ); app.MapControllerRoute( "pfroot", "Forums/{urlName}/{pageNumber}", new { controller = ForumController.Name, action = "Index", Area = "Forums" }, new { forum = forumConstraint } ); app.MapControllerRoute( "pftopic1", "Forums/Topic/{id}", new { controller = ForumController.Name, action = "Topic", pageNumber = 1, Area = "Forums" } ); app.MapControllerRoute( "pftopic", "Forums/Topic/{id}/{pageNumber}", new { controller = ForumController.Name, action = "Topic", Area = "Forums" } ); app.MapControllerRoute( "pflink", "Forums/PostLink/{id}", new { controller = ForumController.Name, action = "PostLink", Area = "Forums" } ); app.MapControllerRoute( "pfsubtopics1", "Forums/Subscription/Topics", new { controller = SubscriptionController.Name, action = "Topics", pageNumber = 1, Area = "Forums" } ); app.MapControllerRoute( "pfsubtopics", "Forums/Subscription/Topics/{pageNumber}", new { controller = SubscriptionController.Name, action = "Topics", Area = "Forums" } ); app.MapControllerRoute( "pffavetopics1", "Forums/Favorites/Topics", new { controller = FavoritesController.Name, action = "Topics", pageNumber = 1, Area = "Forums" } ); app.MapControllerRoute( "pffavetopics", "Forums/Favorites/Topics/{pageNumber}", new { controller = FavoritesController.Name, action = "Topics", Area = "Forums" } ); app.MapControllerRoute( "pfpagedusertopics1", "Forums/Account/Posts/{id}", new { controller = AccountController.Name, action = "Posts", pageNumber = 1, Area = "Forums" } ); app.MapControllerRoute( "pfpagedusertopics", "Forums/Account/Posts/{id}/{pageNumber}", new { controller = AccountController.Name, action = "Posts", Area = "Forums" } ); app.MapControllerRoute( "pftopicunsub", "Forums/Subscription/Unsubscribe/{topicID}/{authKey}", new { controller = SubscriptionController.Name, action = "Unsubscribe", Area = "Forums" } ); app.MapControllerRoute( "pfignore", "Forums/Ignore/{action}", new { controller = IgnoreController.Name, Area = "Forums" } ); return app; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/ForumRouteConstraint.cs ================================================ namespace PopForums.Mvc.Areas.Forums; public class ForumRouteConstraint : IRouteConstraint { public ForumRouteConstraint(IForumRepository forumRepository) { _forumRepository = forumRepository; } private readonly IForumRepository _forumRepository; public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection) { if (!values.Keys.Contains("urlName")) return false; IEnumerable forumUrlNames; try { forumUrlNames = _forumRepository.GetAllForumUrlNames().Result; } catch (Exception exc) { throw new Exception("Can't read forum URL names from data store.", exc); } if (forumUrlNames.Contains(values["urlName"])) return true; return false; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Messaging/Broker.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Messaging; public class Broker : IBroker { public Broker(IForumRepository forumRepo, ITenantService tenantService, IHubContext popForumsHubContext) { _forumRepo = forumRepo; _tenantService = tenantService; _popForumsHubContext = popForumsHubContext; } private readonly IForumRepository _forumRepo; private readonly ITenantService _tenantService; private readonly IHubContext _popForumsHubContext; public void NotifyNewPosts(Topic topic, int lasPostID) { var tenant = _tenantService.GetTenant(); _popForumsHubContext.Clients.Group($"{tenant}:topic:{topic.TopicID}").SendAsync("notifyNewPosts", lasPostID); } public void NotifyNewPost(Topic topic, int postID) { var tenant = _tenantService.GetTenant(); _popForumsHubContext.Clients.Group($"{tenant}:topic:{topic.TopicID}").SendAsync("fetchNewPost", postID); } public void NotifyForumUpdate(Forum forum) { var tenant = _tenantService.GetTenant(); _popForumsHubContext.Clients.Group($"{tenant}:forum:all").SendAsync("notifyForumUpdate", new { forum.ForumID, TopicCount = forum.TopicCount.ToString("N0"), PostCount = forum.PostCount.ToString("N0"), forum.LastPostName, Utc = forum.LastPostTime.ToString("o") }); } public void NotifyTopicUpdate(Topic topic, Forum forum, string topicLink) { var tenant = _tenantService.GetTenant(); var isForumViewRestricted = _forumRepo.GetForumViewRoles(forum.ForumID).Result.Count > 0; var result = new { Link = topicLink, topic.TopicID, topic.StartedByName, topic.Title, ForumTitle = forum.Title, topic.ViewCount, topic.ReplyCount, Utc = topic.LastPostTime.ToString("o"), topic.LastPostName }; if (isForumViewRestricted) _popForumsHubContext.Clients.Group($"{tenant}:recent:{forum.ForumID}").SendAsync("notifyRecentUpdate", result); else _popForumsHubContext.Clients.Group($"{tenant}:recent:all").SendAsync("notifyRecentUpdate", result); _popForumsHubContext.Clients.Group($"{tenant}:forum:{forum.ForumID}").SendAsync("notifyUpdatedTopic", result); } public async void NotifyPMCount(int userID, int pmCount) { var tenantID = _tenantService.GetTenant(); var userIDString = PopForumsUserIdProvider.FormatUserID(tenantID, userID); await _popForumsHubContext.Clients.User(userIDString).SendAsync("updatePMCount", pmCount); } public async void NotifyUser(Notification notification) { var tenantID = _tenantService.GetTenant(); var userIDString = PopForumsUserIdProvider.FormatUserID(tenantID, notification.UserID); await _popForumsHubContext.Clients.User(userIDString).SendAsync("notify", notification); } public async void NotifyUser(Notification notification, string tenantID) { var userIDString = PopForumsUserIdProvider.FormatUserID(tenantID, notification.UserID); await _popForumsHubContext.Clients.User(userIDString).SendAsync("notify", notification); } public async void SendPMMessage(PrivateMessagePost post) { var message = ClientPrivateMessagePost.MapForClient(post); var tenantID = _tenantService.GetTenant(); await _popForumsHubContext.Clients.Group($"{tenantID}:pm:{post.PMID}").SendAsync("addMessage", message); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Messaging/PopForumsHub.cs ================================================ using PopForums.Mvc.Areas.Forums.Authentication; namespace PopForums.Mvc.Areas.Forums.Messaging; public class PopForumsHub : Hub { private readonly ITenantService _tenantService; private readonly IUserService _userService; private readonly IForumService _forumService; private readonly ITopicService _topicService; private readonly INotificationManager _notificationManager; private readonly IPrivateMessageService _privateMessageService; public PopForumsHub(ITenantService tenantService, IUserService userService, IForumService forumService, ITopicService topicService, INotificationManager notificationManager, IPrivateMessageService privateMessageService) { _tenantService = tenantService; _userService = userService; _forumService = forumService; _topicService = topicService; _notificationManager = notificationManager; _privateMessageService = privateMessageService; } // *** Forums public void ListenToAllForums() { var tenant = _tenantService.GetTenant(); Groups.AddToGroupAsync(Context.ConnectionId, $"{tenant}:forum:all"); } public void ListenToForum(int forumID) { var tenant = _tenantService.GetTenant(); Groups.AddToGroupAsync(Context.ConnectionId, $"{tenant}:forum:{forumID}"); } // *** Recent public void ListenRecent() { var tenant = _tenantService.GetTenant(); Groups.AddToGroupAsync(Context.ConnectionId, $"{tenant}:recent:all"); var principal = Context.User; if (principal != null && principal.Identity != null) { var user = _userService.GetUserByName(principal.Identity.Name).Result; var visibleForumIDs = _forumService.GetViewableForumIDsFromViewRestrictedForums(user).Result; foreach (var forumID in visibleForumIDs) Groups.AddToGroupAsync(Context.ConnectionId, $"{tenant}:recent:{forumID}"); } } // *** Topics public void ListenToTopic(int topicID) { var tenant = _tenantService.GetTenant(); Groups.AddToGroupAsync(Context.ConnectionId, $"{tenant}:topic:{topicID}"); } public int GetLastPostID(int topicID) { return _topicService.TopicLastPostID(topicID).Result; } // *** Notifications private int GetUserID() { var userIDstring = Context.User?.Claims.FirstOrDefault(x => x.Type == PopForumsAuthenticationDefaults.ForumsUserIDType); if (userIDstring == null) throw new Exception("No forum user ID claim found in hub context of User"); var userID = Convert.ToInt32(userIDstring.Value); return userID; } public async Task MarkNotificationRead(long contextID, int notificationType) { var notificationTypeEnum = (NotificationType)notificationType; var userID = GetUserID(); await _notificationManager.MarkNotificationRead(userID, notificationTypeEnum, contextID); } public async Task MarkAllRead() { var userID = GetUserID(); await _notificationManager.MarkAllRead(userID); } public async Task> GetNotifications(DateTime afterDateTime) { var userID = GetUserID(); var notifications = await _notificationManager.GetNotifications(userID, afterDateTime); return notifications; } public async Task GetNotificationCount() { var userID = GetUserID(); var count = await _notificationManager.GetUnreadNotificationCount(userID); return count; } public async Task GetPMCount() { var userID = GetUserID(); var count = await _privateMessageService.GetUnreadCount(userID); return count; } // *** Private Messages public async Task ListenToPm(int pmID) { var userID = GetUserID(); if (!await _privateMessageService.IsUserInPM(userID, pmID)) return; var tenant = _tenantService.GetTenant(); await Groups.AddToGroupAsync(Context.ConnectionId, $"{tenant}:pm:{pmID}"); } public async Task SendPm(int pmID, string fullText) { var userID = GetUserID(); if (!await _privateMessageService.IsUserInPM(userID, pmID)) return; var pm = await _privateMessageService.Get(pmID, userID); var user = await _userService.GetUser(userID); await _privateMessageService.Reply(pm, fullText, user); } public async Task AckReadPm(int pmID) { var userID = GetUserID(); await _privateMessageService.MarkPMRead(userID, pmID); } public async Task GetPmPosts(int pmID, DateTime beforeDateTime) { var userID = GetUserID(); if (!await _privateMessageService.IsUserInPM(userID, pmID)) { return null; } var posts = await _privateMessageService.GetPosts(pmID, beforeDateTime); var clientMessages = ClientPrivateMessagePost.MapForClient(posts); return clientMessages; } public async Task GetMostRecentPmPosts(int pmID, DateTime afterDateTime) { var userID = GetUserID(); if (!await _privateMessageService.IsUserInPM(userID, pmID)) { return null; } var posts = await _privateMessageService.GetMostRecentPosts(pmID, afterDateTime); var clientMessages = ClientPrivateMessagePost.MapForClient(posts); return clientMessages; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Messaging/PopForumsUserIdProvider.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Messaging; public class PopForumsUserIdProvider : IUserIdProvider { private readonly IUserRetrievalShim _userRetrievalShim; private readonly ITenantService _tenantService; public PopForumsUserIdProvider(IUserRetrievalShim userRetrievalShim, ITenantService tenantService) { _userRetrievalShim = userRetrievalShim; _tenantService = tenantService; } public string GetUserId(HubConnectionContext connection) { var user = _userRetrievalShim.GetUser(); if (user == null) return null; var tenantID = _tenantService.GetTenant(); var id = FormatUserID(tenantID, user.UserID); return id; } public static string FormatUserID(string tenantID, int userID) { return $"{tenantID}:{userID}"; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Models/AwardConditionDeleteContainer.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Models; public class AwardConditionDeleteContainer { public string AwardDefinitionID { get; set; } public string EventDefinitionID { get; set; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Models/EmailUsersContainer.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Models; public class EmailUsersContainer { public string Subject { get; set; } public string Body { get; set; } public string HtmlBody { get; set; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Models/ExternalLoginState.cs ================================================ using PopIdentity; namespace PopForums.Mvc.Areas.Forums.Models; public class ExternalLoginState { public ResultData ResultData { get; set; } public string ReturnUrl { get; set; } public ProviderType ProviderType { get; set; } public DateTime? Expiration { get; set; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Models/ExternalLoginTypeMetadata.cs ================================================ using PopIdentity; namespace PopForums.Mvc.Areas.Forums.Models; public class ExternalLoginTypeMetadata { public string Name { get; set; } public string CssClass { get; set; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Models/IPHistoryQuery.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Models; public class IPHistoryQuery { public string IP { get; set; } public DateTime Start { get; set; } public DateTime End { get; set; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Models/ManualEvent.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Models; public class ManualEvent { public int UserID { get; set; } public string Message { get; set; } public int? Points { get; set; } public string EventDefinitionID { get; set; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Models/SecurityLogQuery.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Models; public class SecurityLogQuery { public string SearchTerm { get; set; } public string Type { get; set; } public DateTime Start { get; set; } public DateTime End { get; set; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Models/UserEditPhoto.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Models; public class UserEditPhoto { public UserEditPhoto() { } public UserEditPhoto(Profile profile) { AvatarID = profile.AvatarID; ImageID = profile.ImageID; } public int? AvatarID { get; set; } public int? ImageID { get; set; } public bool? IsImageApproved { get; set; } public bool DeleteAvatar { get; set; } public bool DeleteImage { get; set; } public IFormFile AvatarFile { get; set; } public IFormFile PhotoFile { get; set; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Models/UserEditWithFiles.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Models; public class UserEditWithFiles : UserEdit { public UserEditWithFiles() { } public UserEditWithFiles(User user, Profile profile) : base(user, profile) { } public IFormFile AvatarFile { get; set; } public IFormFile PhotoFile { get; set; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Models/UserState.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Models; public class UserState { public bool IsImageEnabled { get; set; } public bool IsPlainText { get; set; } public int NewPmCount { get; set; } public int NotificationCount { get; set; } public int UserID { get; set; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Services/ExternalLoginRoutingService.cs ================================================ using PopIdentity; namespace PopForums.Mvc.Areas.Forums.Services; public interface IExternalLoginRoutingService { Dictionary GetActiveProviderTypeAndNameDictionary(); } public class ExternalLoginRoutingService : IExternalLoginRoutingService { private readonly ISettingsManager _settingsManager; public ExternalLoginRoutingService(ISettingsManager settingsManager) { _settingsManager = settingsManager; } public Dictionary GetActiveProviderTypeAndNameDictionary() { var dictionary = new Dictionary(); if (_settingsManager.Current.UseGoogleLogin) dictionary.Add(ProviderType.Google, new ExternalLoginTypeMetadata { Name = "Google", CssClass = "icon-google" }); if (_settingsManager.Current.UseFacebookLogin) dictionary.Add(ProviderType.Facebook, new ExternalLoginTypeMetadata { Name = "Facebook", CssClass = "icon-facebook" }); if (_settingsManager.Current.UseMicrosoftLogin) dictionary.Add(ProviderType.Microsoft, new ExternalLoginTypeMetadata { Name = "Microsoft", CssClass = "icon-microsoft" }); if (_settingsManager.Current.UseOAuth2Login) dictionary.Add(ProviderType.OAuth2, new ExternalLoginTypeMetadata { Name = _settingsManager.Current.OAuth2DisplayName, CssClass = "" }); return dictionary; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Services/ExternalLoginTempService.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Services; public interface IExternalLoginTempService { void Persist(ExternalLoginState externalLoginState); ExternalLoginState Read(); void Remove(); } public class ExternalLoginTempService : IExternalLoginTempService { private readonly IDataProtectionProvider _dataProtectionProvider; private readonly IHttpContextAccessor _httpContextAccessor; private const string CookieKey = "pf_temploginstate"; public ExternalLoginTempService(IDataProtectionProvider dataProtectionProvider, IHttpContextAccessor httpContextAccessor) { _dataProtectionProvider = dataProtectionProvider; _httpContextAccessor = httpContextAccessor; } public void Persist(ExternalLoginState externalLoginState) { var protector = _dataProtectionProvider.CreateProtector(nameof(ExternalLoginTempService)); var serializedResult = JsonSerializer.Serialize(externalLoginState); var encryptedResult = protector.Protect(serializedResult); _httpContextAccessor.HttpContext.Response.Cookies.Append(CookieKey, encryptedResult); } public ExternalLoginState Read() { var protector = _dataProtectionProvider.CreateProtector(nameof(ExternalLoginTempService)); var encryptedTempAuth = _httpContextAccessor.HttpContext.Request.Cookies[CookieKey]; if (string.IsNullOrEmpty(encryptedTempAuth)) { return null; } var decryptedSerialized = protector.Unprotect(encryptedTempAuth); var result = JsonSerializer.Deserialize(decryptedSerialized); return result; } public void Remove() { _httpContextAccessor.HttpContext.Response.Cookies.Delete(CookieKey); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Services/ForumAdapterFactory.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Services; public class ForumAdapterFactory { public ForumAdapterFactory(Forum forum) { if (!string.IsNullOrWhiteSpace(forum.ForumAdapterName)) { var type = Type.GetType(forum.ForumAdapterName); if (type == null) throw new Exception($"Can't find ForumAdapter \"{forum.ForumAdapterName}\" (Forum ID: {forum.ForumID}, Title: {forum.Title})"); var instance = Activator.CreateInstance(type); if (!typeof(IForumAdapter).IsAssignableFrom(instance.GetType())) throw new Exception($"ForumAdapter \"{forum.ForumAdapterName}\" does not implement IForumAdapter (Forum ID: {forum.ForumID}, Title: {forum.Title})"); ForumAdapter = (IForumAdapter)instance; } } public bool IsAdapterEnabled => ForumAdapter != null; public IForumAdapter ForumAdapter { get; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Services/IForumAdapter.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Services; /// /// Enables alternate views and processing to individual forums. /// /// /// Concrete implementations of this interface can be specified in the admin forum properties. The /// Index, Topic and PostLink methods of the forum controller will alter their behavior based on /// the implementation. See individual members for details. /// public interface IForumAdapter { /// /// This method allows you to change the view and model generated by the Index method of the ForumController by setting /// the properties on the adapter. /// /// Instance of the controller calling the adapter. /// The composed ForumTopicContainer created by the Index method of the ForumController. Task AdaptForum(Controller controller, ForumTopicContainer forumTopicContainer); /// /// This method allows you to change the view and model generated by the Topic method of the ForumController by setting /// the properties on the adapter. /// /// Instance of the controller calling the adapter. /// The composed TopicContainer created by the Topic method of the ForumController. Task AdaptTopic(Controller controller, TopicContainer topicContainer); /// /// This method allows you to override the behavior of the ForumController's PostLink /// method. Use this if you need the links to redirect to some other location, otherwise /// return null. /// /// Instance of the controller calling the adapter. /// The post associated with the id parameter of the PostLink method. /// The topic associated with the post. /// The forum associated with the topic. /// Task AdaptPostLink(Controller controller, Post post, Topic topic, Forum forum); /// /// Set this in the adapter for alternate views from the AdaptForum and AdaptTopic methods. /// string ViewName { get; } /// /// Set this in the adapter for alternate an alternate model from the AdaptForum and AdaptTopic methods. /// object Model { get; } /// /// To allow the controller to mark the topic as read, set this to true, otherwise, false. /// bool MarkViewedTopicRead { get; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Services/OAuthOnlyService.cs ================================================ using PopIdentity; using PopIdentity.Providers.OAuth2; namespace PopForums.Mvc.Areas.Forums.Services; public interface IOAuthOnlyService { string GetLoginUrl(string redirectUrl); Task ProcessOAuthLogin(string redirectUrl, string ip); Task AttemptTokenRefresh(User user); } public class OAuthOnlyService : IOAuthOnlyService { private readonly IConfig _config; private readonly IOAuth2LoginUrlGenerator _oAuth2LoginUrlGenerator; private readonly IStateHashingService _stateHashingService; private readonly IOAuth2JwtCallbackProcessor _oAuth2JwtCallbackProcessor; private readonly IExternalUserAssociationManager _externalUserAssociationManager; private readonly IUserService _userService; private readonly IClaimsToRoleMapper _claimsToRoleMapper; private readonly IUserNameReconciler _userNameReconciler; private readonly IUserEmailReconciler _userEmailReconciler; private readonly ISecurityLogService _securityLogService; public OAuthOnlyService(IConfig config, IOAuth2LoginUrlGenerator oAuth2LoginUrlGenerator, IStateHashingService stateHashingService, IOAuth2JwtCallbackProcessor oAuth2JwtCallbackProcessor, IExternalUserAssociationManager externalUserAssociationManager, IUserService userService, IClaimsToRoleMapper claimsToRoleMapper, IUserNameReconciler userNameReconciler, IUserEmailReconciler userEmailReconciler, ISecurityLogService securityLogService) { _config = config; _oAuth2LoginUrlGenerator = oAuth2LoginUrlGenerator; _stateHashingService = stateHashingService; _oAuth2JwtCallbackProcessor = oAuth2JwtCallbackProcessor; _externalUserAssociationManager = externalUserAssociationManager; _userService = userService; _claimsToRoleMapper = claimsToRoleMapper; _userNameReconciler = userNameReconciler; _userEmailReconciler = userEmailReconciler; _securityLogService = securityLogService; } public string GetLoginUrl(string redirectUrl) { var state = _stateHashingService.SetCookieAndReturnHash(); var url = _oAuth2LoginUrlGenerator.GetUrl(_config.OAuthLoginBaseUrl, _config.OAuthClientID, redirectUrl, state, _config.OAuthScopes); return url; } public async Task ProcessOAuthLogin(string redirectUrl, string ip) { var callbackResult = await _oAuth2JwtCallbackProcessor.VerifyCallback(redirectUrl, _config.OAuthTokenUrl, _config.OAuthClientID, _config.OAuthClientSecret); if (!callbackResult.IsSuccessful) { await _securityLogService.CreateLogEntry((User)null, null, ip, callbackResult.Message, SecurityLogType.ExternalLoginChallengeFailed); return callbackResult; } if (string.IsNullOrEmpty(callbackResult.ResultData.Name)) { callbackResult.IsSuccessful = false; callbackResult.Message = "Identity provider did not return a name."; } if (string.IsNullOrEmpty(callbackResult.ResultData.Email)) { callbackResult.IsSuccessful = false; callbackResult.Message = "Identity provider did not return an email."; } if (string.IsNullOrEmpty(callbackResult.ResultData.ID)) { callbackResult.IsSuccessful = false; callbackResult.Message = "Identity provider did not return a unique identifier."; } // lookup the external user var externalLoginInfo = new ExternalLoginInfo( ProviderType.OAuthOnly.ToString(), callbackResult.ResultData.ID, callbackResult.ResultData.Name); var matchResult = await _externalUserAssociationManager.ExternalUserAssociationCheck(externalLoginInfo, ip); User user; if (!matchResult.Successful) { // if not found, create the new user // reconcile email var uniqueEmail = await _userEmailReconciler.GetUniqueEmail(callbackResult.ResultData.Email, callbackResult.ResultData.ID); // reconcile name var uniqueName = await _userNameReconciler.GetUniqueNameForUser(callbackResult.ResultData.Name); var signupData = new SignupData { Name = uniqueName, Email = uniqueEmail, Password = Guid.NewGuid().ToString(), IsCoppa = true, IsTos = true, IsSubscribed = true, IsAutoFollowOnReply = true }; user = await _userService.CreateUserWithProfile(signupData, ip); await _externalUserAssociationManager.Associate(user, externalLoginInfo, ip); } else { // if found, verify name is correct user = matchResult.User; // reconcile name if (user.Name != callbackResult.ResultData.Name) { var updatedName = await _userNameReconciler.GetUniqueNameForUser(callbackResult.ResultData.Name); await _userService.ChangeName(user, updatedName, null, ip); } } // set the token expiration await _userService.UpdateTokenExpiration(user, DateTime.UtcNow.AddMinutes(_config.OAuthRefreshExpirationMinutes)); // update refresh token await _userService.UpdateRefreshToken(user, callbackResult.RefreshToken); // set admin/mod based on claims await _claimsToRoleMapper.MapRoles(user, callbackResult.Claims); return callbackResult; } public async Task AttemptTokenRefresh(User user) { var previousToken = await _userService.GetRefreshToken(user); var callbackResult = await _oAuth2JwtCallbackProcessor.GetRefreshToken(previousToken, _config.OAuthTokenUrl, _config.OAuthClientID, _config.OAuthClientSecret); if (callbackResult.IsSuccessful) { await _userService.UpdateRefreshToken(user, callbackResult.RefreshToken); await _userService.UpdateTokenExpiration(user, DateTime.UtcNow.AddMinutes(_config.OAuthRefreshExpirationMinutes)); } return callbackResult.IsSuccessful; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Services/TopicViewCountService.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Services; public class TopicViewCountService : ITopicViewCountService { public TopicViewCountService(ITopicRepository topicRepository, IHttpContextAccessor httpContextAccessor) { _topicRepository = topicRepository; _httpContextAccessor = httpContextAccessor; } private readonly ITopicRepository _topicRepository; private readonly IHttpContextAccessor _httpContextAccessor; private const string CookieKey = "PopForums.LastTopicID"; public async Task ProcessView(Topic topic) { var context = _httpContextAccessor.HttpContext; if (context != null && context.Request.Cookies != null && context.Request.Cookies.ContainsKey(CookieKey)) { if (int.TryParse(context.Request.Cookies[CookieKey], out var topicID)) { if (topicID != topic.TopicID) await _topicRepository.IncrementViewCount(topic.TopicID); } } else await _topicRepository.IncrementViewCount(topic.TopicID); SetViewedTopic(topic); } public void SetViewedTopic(Topic topic) { _httpContextAccessor?.HttpContext?.Response?.Cookies?.Append(CookieKey, topic.TopicID.ToString()); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Services/UserRetrievalShim.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Services; public class UserRetrievalShim : IUserRetrievalShim { private readonly IHttpContextAccessor _httpContextAccessor; public UserRetrievalShim(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; } public User GetUser() { var user = _httpContextAccessor.HttpContext?.Items["PopForumsUser"] as User; return user; } public Profile GetProfile() { var profile = _httpContextAccessor.HttpContext?.Items["PopForumsProfile"] as Profile; return profile; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Services/UserStateComposer.cs ================================================ namespace PopForums.Mvc.Areas.Forums.Services; public interface IUserStateComposer { Task GetState(); } // TODO: move this to base library public class UserStateComposer : IUserStateComposer { private readonly IUserRetrievalShim _userRetrievalShim; private readonly IPrivateMessageService _privateMessageService; private readonly ISettingsManager _settingsManager; private readonly INotificationManager _notificationManager; public UserStateComposer(IUserRetrievalShim userRetrievalShim, IPrivateMessageService privateMessageService, ISettingsManager settingsManager, INotificationManager notificationManager) { _userRetrievalShim = userRetrievalShim; _privateMessageService = privateMessageService; _settingsManager = settingsManager; _notificationManager = notificationManager; } public async Task GetState() { var state = new UserState(); var user = _userRetrievalShim.GetUser(); if (user != null) { var profile = _userRetrievalShim.GetProfile(); state.IsPlainText = profile.IsPlainText; state.NewPmCount = await _privateMessageService.GetUnreadCount(user.UserID); state.IsImageEnabled = _settingsManager.Current.AllowImages; state.NotificationCount = await _notificationManager.GetUnreadNotificationCount(user.UserID); state.UserID = user.UserID; } return state; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/TagHelpers/ForumReadIndicatorTagHelper.cs ================================================ namespace PopForums.Mvc.Areas.Forums.TagHelpers; [HtmlTargetElement("pf-forumReadIndicator", Attributes = "forum, categorizedForumContainer")] public class ForumReadIndicatorTagHelper : TagHelper { [HtmlAttributeName("forum")] public Forum Forum { get; set; } [HtmlAttributeName("categorizedForumContainer")] public CategorizedForumContainer CategorizedForumContainer { get; set; } [HtmlAttributeName("class")] public string Class { get; set; } public override void Process(TagHelperContext context, TagHelperOutput output) { var alt = Resources.NoNewPosts; if (CategorizedForumContainer.ReadStatusLookup.ContainsKey(Forum.ForumID)) { var status = CategorizedForumContainer.ReadStatusLookup[Forum.ForumID]; switch (status) { case ReadStatus.Closed | ReadStatus.NoNewPosts: alt = Resources.Archived; output.PostElement.AppendHtml(""); break; case ReadStatus.Closed | ReadStatus.NewPosts: alt = Resources.ArchivedNewPosts; output.PostElement.AppendHtml(""); break; case ReadStatus.NewPosts: alt = Resources.NewPosts; output.PostElement.AppendHtml(""); break; default: output.PostElement.AppendHtml(""); break; } } output.TagName = "div"; output.Attributes.Add("title", alt); if (!String.IsNullOrWhiteSpace(Class)) output.Attributes.Add("class", $"topicIndicator {Class}"); else output.Attributes.Add("class", "topicIndicator"); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/TagHelpers/PMReadIndicatorTagHelper.cs ================================================ namespace PopForums.Mvc.Areas.Forums.TagHelpers; [HtmlTargetElement("pf-pmReadIndicator", Attributes = "privateMessage")] public class PMReadIndicatorTagHelper : TagHelper { [HtmlAttributeName("class")] public string Class { get; set; } [HtmlAttributeName("privateMessage")] public PrivateMessage PrivateMessage { get; set; } public override void Process(TagHelperContext context, TagHelperOutput output) { if (PrivateMessage.LastPostTime > PrivateMessage.LastViewDate) { output.Attributes.Add("title", Resources.NewPosts); output.PostElement.AppendHtml(""); } else { output.Attributes.Add("title", Resources.NoNewPosts); output.PostElement.AppendHtml(""); } output.TagName = "div"; if (!String.IsNullOrWhiteSpace(Class)) output.Attributes.Add("class", $"topicIndicator {Class}"); else output.Attributes.Add("class", "topicIndicator"); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/TagHelpers/PagerLinksTagHelper.cs ================================================ namespace PopForums.Mvc.Areas.Forums.TagHelpers; [HtmlTargetElement("pf-pagerLinks", Attributes = "controllerName, actionName, pagerContext")] public class PagerLinksTagHelper : TagHelper { private readonly IHtmlGenerator _htmlGenerator; [HtmlAttributeName("controllerName")] public string ControllerName { get; set; } [HtmlAttributeName("actionName")] public string ActionName { get; set; } [HtmlAttributeName("pagerContext")] public PagerContext PagerContext { get; set; } [HtmlAttributeName("class")] public string Class { get; set; } [HtmlAttributeName("moreTextClass")] public string MoreTextClass { get; set; } [HtmlAttributeName("currentTextClass")] public string CurrentTextClass { get; set; } [HtmlAttributeName("routeParameters")] public Dictionary RouteParameters { get; set; } [HtmlAttributeName("low")] public int Low { get; set; } [HtmlAttributeName("high")] public int High { get; set; } [HtmlAttributeNotBound] [ViewContext] public ViewContext ViewContext { get; set; } public PagerLinksTagHelper(IHtmlGenerator htmlGenerator) { _htmlGenerator = htmlGenerator; } public override void Process(TagHelperContext context, TagHelperOutput output) { if (PagerContext == null) { output.TagName = "div"; output.TagMode = TagMode.StartTagAndEndTag; return; } if (String.IsNullOrEmpty(ControllerName) || String.IsNullOrEmpty(ActionName)) throw new Exception("controllerName and actionName must be specified for PageLinks."); if (PagerContext.PageCount <= 1) { output.TagName = "div"; output.TagMode = TagMode.StartTagAndEndTag; return; } var builder = new StringBuilder(); if (String.IsNullOrEmpty(MoreTextClass)) builder.Append($"
  • {Resources.More}:
  • "); else builder.Append($"
  • {Resources.More}
  • "); if (PagerContext.PageIndex != 1 && Low != 1) { // first page link builder.Append("
  • "); var firstRouteDictionary = new RouteValueDictionary(new { controller = ControllerName, action = ActionName, pageNumber = 1 }); if (RouteParameters != null) foreach (var item in RouteParameters) firstRouteDictionary.Add(item.Key, item.Value); var firstLink = _htmlGenerator.GenerateActionLink(ViewContext, "", ActionName, ControllerName, null, null, null, firstRouteDictionary, new { title = Resources.First, @class = "page-link" }); firstLink.InnerHtml.SetHtmlContent(""); builder.Append(GetString(firstLink)); builder.Append("
  • "); if (PagerContext.PageIndex > 2) { // previous page link var previousIndex = PagerContext.PageIndex - 1; if (Low != 0) previousIndex = Low - 1; builder.Append("
  • "); var previousRouteDictionary = new RouteValueDictionary(new { controller = ControllerName, action = ActionName, pageNumber = previousIndex }); if (RouteParameters != null) foreach (var item in RouteParameters) previousRouteDictionary.Add(item.Key, item.Value); var previousLink = _htmlGenerator.GenerateActionLink(ViewContext, "", ActionName, ControllerName, null, null, null, previousRouteDictionary, new { title = Resources.Previous, rel = "prev", @class = "page-link" }); previousLink.InnerHtml.SetHtmlContent(""); builder.Append(GetString(previousLink)); builder.Append("
  • "); } } if (Low == 0 || High == 0) { // not a multipage set of links used in partial page // calc low and high limits for numeric links var low = PagerContext.PageIndex - 1; var high = PagerContext.PageIndex + 3; if (low < 1) low = 1; if (high > PagerContext.PageCount) high = PagerContext.PageCount; if (high - low < 5) while ((high < low + 4) && high < PagerContext.PageCount) high++; if (high - low < 5) while ((low > high - 4) && low > 1) low--; for (var x = low; x < high + 1; x++) { // numeric links if (x == PagerContext.PageIndex) { if (String.IsNullOrEmpty(CurrentTextClass)) builder.Append($"
  • {x} of {PagerContext.PageCount}
  • "); else builder.Append($"
  • {x} of {PagerContext.PageCount}
  • "); } else { builder.Append("
  • "); var numericRouteDictionary = new RouteValueDictionary { { "controller", ControllerName }, { "action", ActionName }, { "pageNumber", x } }; if (RouteParameters != null) foreach (var item in RouteParameters) numericRouteDictionary.Add(item.Key, item.Value); var link = _htmlGenerator.GenerateActionLink(ViewContext, x.ToString(), ActionName, ControllerName, null, null, null, numericRouteDictionary, new { @class = "page-link" }); builder.Append(GetString(link)); builder.Append("
  • "); } } } else { // multipage set of links used in partial page // calc low and high limits for numeric links var calcLow = PagerContext.PageIndex - 1; var calcHigh = PagerContext.PageIndex + 3; if (calcLow < 1) calcLow = 1; if (calcHigh > PagerContext.PageCount) calcHigh = PagerContext.PageCount; if (calcHigh - calcLow < 5) while ((calcHigh < calcLow + 4) && calcHigh < PagerContext.PageCount) calcHigh++; if (calcHigh - calcLow < 5) while ((calcLow > calcHigh - 4) && calcLow > 1) calcLow--; var isRangeRendered = false; for (var x = calcLow; x < calcHigh + 1; x++) { // numeric links if (x >= Low && x <= High) { if (!isRangeRendered) { isRangeRendered = true; if (String.IsNullOrEmpty(CurrentTextClass)) builder.Append($"
  • {Low}-{High} of {PagerContext.PageCount}
  • "); else builder.Append($"
  • {Low}-{High} of {PagerContext.PageCount}
  • "); } } else { builder.Append("
  • "); var numericRouteDictionary = new RouteValueDictionary { { "controller", ControllerName }, { "action", ActionName }, { "pageNumber", x } }; if (RouteParameters != null) foreach (var item in RouteParameters) numericRouteDictionary.Add(item.Key, item.Value); var link = _htmlGenerator.GenerateActionLink(ViewContext, x.ToString(), ActionName, ControllerName, null, null, null, numericRouteDictionary, new { @class = "page-link" }); builder.Append(GetString(link)); builder.Append("
  • "); } } } if (PagerContext.PageIndex != PagerContext.PageCount && High < PagerContext.PageCount) { if (PagerContext.PageIndex < PagerContext.PageCount - 1) { // next page link var nextIndex = PagerContext.PageIndex + 1; builder.Append("
  • "); var nextRouteDictionary = new RouteValueDictionary(new { controller = ControllerName, action = ActionName, pageNumber = nextIndex }); if (RouteParameters != null) foreach (var item in RouteParameters) nextRouteDictionary.Add(item.Key, item.Value); var nextLink = _htmlGenerator.GenerateActionLink(ViewContext, "", ActionName, ControllerName, null, null, null, nextRouteDictionary, new { title = Resources.Next, rel = "next", @class = "page-link" }); nextLink.InnerHtml.SetHtmlContent(""); builder.Append(GetString(nextLink)); builder.Append("
  • "); } // last page link builder.Append("
  • "); var lastRouteDictionary = new RouteValueDictionary(new { controller = ControllerName, action = ActionName, pageNumber = PagerContext.PageCount }); if (RouteParameters != null) foreach (var item in RouteParameters) lastRouteDictionary.Add(item.Key, item.Value); var lastLink = _htmlGenerator.GenerateActionLink(ViewContext, "", ActionName, ControllerName, null, null, null, lastRouteDictionary, new { title = Resources.Last, @class = "page-link" }); lastLink.InnerHtml.SetHtmlContent(""); builder.Append(GetString(lastLink)); builder.Append("
  • "); } output.TagMode = TagMode.StartTagAndEndTag; output.TagName = "ul"; if (!String.IsNullOrWhiteSpace(Class)) output.Attributes.Add("class", Class); output.Content.AppendHtml(builder.ToString()); } private static string GetString(IHtmlContent content) { var writer = new System.IO.StringWriter(); content.WriteTo(writer, HtmlEncoder.Default); return writer.ToString(); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/TagHelpers/TopicReadIndicatorTagHelper.cs ================================================ namespace PopForums.Mvc.Areas.Forums.TagHelpers; [HtmlTargetElement("pf-topicReadIndicator", Attributes = "topic, pagedTopicContainer")] public class TopicReadIndicatorTagHelper : TagHelper { [HtmlAttributeName("topic")] public Topic Topic { get; set; } [HtmlAttributeName("pagedTopicContainer")] public PagedTopicContainer PagedTopicContainer { get; set; } [HtmlAttributeName("class")] public string Class { get; set; } public override void Process(TagHelperContext context, TagHelperOutput output) { var alt = Resources.NoNewPosts; if (PagedTopicContainer.ReadStatusLookup.ContainsKey(Topic.TopicID)) { var status = PagedTopicContainer.ReadStatusLookup[Topic.TopicID]; switch (status) { case ReadStatus.Open | ReadStatus.NewPosts | ReadStatus.Pinned: output.PostElement.AppendHtml(""); alt = Resources.NewPostsPinned; break; case ReadStatus.Open | ReadStatus.NewPosts | ReadStatus.NotPinned: output.PostElement.AppendHtml(""); alt = Resources.NewPosts; break; case ReadStatus.Open | ReadStatus.NoNewPosts | ReadStatus.Pinned: output.PostElement.AppendHtml(""); alt = Resources.Pinned; break; case ReadStatus.Open | ReadStatus.NoNewPosts | ReadStatus.NotPinned: output.PostElement.AppendHtml(""); alt = Resources.NoNewPosts; break; case ReadStatus.Closed | ReadStatus.NewPosts | ReadStatus.Pinned: output.PostElement.AppendHtml(""); alt = Resources.NewPostsClosedPinned; break; case ReadStatus.Closed | ReadStatus.NewPosts | ReadStatus.NotPinned: output.PostElement.AppendHtml(""); alt = Resources.NewPostsClosed; break; case ReadStatus.Closed | ReadStatus.NoNewPosts | ReadStatus.Pinned: output.PostElement.AppendHtml(""); alt = Resources.ClosedPinned; break; case ReadStatus.Closed | ReadStatus.NoNewPosts | ReadStatus.NotPinned: output.PostElement.AppendHtml(""); alt = Resources.Closed; break; default: output.PostElement.AppendHtml(""); break; } } output.TagName = "div"; output.Attributes.Add("title", alt); if (!String.IsNullOrWhiteSpace(Class)) output.Attributes.Add("class", $"topicIndicator {Class}"); else output.Attributes.Add("class", "topicIndicator"); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/TagHelpers/ValidationClassTagHelper.cs ================================================ namespace PopForums.Mvc.Areas.Forums.TagHelpers; [HtmlTargetElement("div", Attributes = ValidationForAttributeName + "," + ValidationErrorClassName)] public class ValidationClassTagHelper : TagHelper { private const string ValidationForAttributeName = "pf-validation-for"; private const string ValidationErrorClassName = "pf-validationerror-class"; [HtmlAttributeName(ValidationForAttributeName)] public ModelExpression For { get; set; } [HtmlAttributeName(ValidationErrorClassName)] public string ValidationErrorClass { get; set; } [HtmlAttributeNotBound] [ViewContext] public ViewContext ViewContext { get; set; } public override void Process(TagHelperContext context, TagHelperOutput output) { ModelStateEntry entry; ViewContext.ViewData.ModelState.TryGetValue(For.Name, out entry); if (entry == null || !entry.Errors.Any()) return; var tagBuilder = new TagBuilder("div"); tagBuilder.AddCssClass(ValidationErrorClass); output.MergeAttributes(tagBuilder); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/ViewComponents/UserNavigationViewComponent.cs ================================================ #pragma warning disable CS1998 namespace PopForums.Mvc.Areas.Forums.ViewComponents; public class UserNavigationViewComponent : ViewComponent { public async Task InvokeAsync() { return View(); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/ViewComponents/UserStateViewComponent.cs ================================================ namespace PopForums.Mvc.Areas.Forums.ViewComponents; public class UserStateViewComponent : ViewComponent { private readonly IUserStateComposer _userStateComposer; public UserStateViewComponent(IUserStateComposer userStateComposer) { _userStateComposer = userStateComposer; } public async Task InvokeAsync() { var container = await _userStateComposer.GetState(); return View(container); } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Account/AccountCreated.cshtml ================================================ @{ ViewBag.Title = PopForums.Resources.AccountCreated; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; }

    @PopForums.Resources.AccountCreated

    @if (ViewData["EmailProblem"] == null) {

    @ViewData["Result"]

    } else {

    @ViewData["EmailProblem"] @ViewData["Result"]

    }

    @PopForums.Resources.EditYourProfile

    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Account/Create.cshtml ================================================ @using PopForums.Configuration @model SignupData @inject IUserRetrievalShim UserRetrievalShim @inject IConfig Config @inject ISettingsManager SettingsManager @{ ViewBag.Title = PopForums.Resources.CreateAnAccount; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; var user = UserRetrievalShim.GetUser(); } @section HeaderContent { @if (Config.UseReCaptcha) { } }

    @PopForums.Resources.CreateAnAccount

    @if (!SettingsManager.Current.IsPrivateForumInstance) { }

    @PopForums.Resources.NeedToVerifyExistingAccount

    @if (user == null) {
    } else {

    @PopForums.Resources.AlreadyCreatedAccount

    } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Account/EditAccountNoUser.cshtml ================================================ @{ ViewBag.Title = PopForums.Resources.EditAccount; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; }

    @PopForums.Resources.EditAccount

    @PopForums.Resources.MustBeRegisteredToEditAccount

    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Account/EditProfile.cshtml ================================================ @model UserEditProfile @inject IUserRetrievalShim UserRetrievalShim @inject IConfig Config @{ ViewBag.Title = PopForums.Resources.Account + " - " + PopForums.Resources.EditProfile; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; var user = UserRetrievalShim.GetUser(); } @section HeaderContent { }

    @PopForums.Resources.Account - @PopForums.Resources.EditProfile

    @if (ViewData["Result"] != null) {

    @ViewData["Result"]

    } @if (!user.IsApproved) {

    @PopForums.Resources.VerifyAccount

    }

    @PopForums.Resources.Options

    @PopForums.Resources.Details

    https://facebook.com/
    @
    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Account/ExternalLogins.cshtml ================================================ @model List @{ ViewBag.Title = PopForums.Resources.Account + " - " + PopForums.Resources.ExternalLogins; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; }

    @PopForums.Resources.Account - @PopForums.Resources.ExternalLogins

    @if (Model.Count == 0) {

    @PopForums.Resources.NoExternalLoginsRegistered

    } else { @foreach (var item in Model) { }
    @item.Issuer @item.Name
    } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Account/Forgot.cshtml ================================================ @using PopForums.Configuration @inject ISettingsManager SettingsManager @{ ViewBag.Title = PopForums.Resources.ForgotPassword; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; }

    @PopForums.Resources.ForgotPassword

    @if (!SettingsManager.Current.IsPrivateForumInstance) { }

    @PopForums.Resources.ForgotInstructions

    @if (ViewBag.Result != null) { @ViewBag.Result }
    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Account/Login.cshtml ================================================ @inject IUserRetrievalShim UserRetrievalShim @inject ISettingsManager SettingsManager @using PopForums.Configuration @model Dictionary @{ ViewBag.Title = PopForums.Resources.Login; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; var user = UserRetrievalShim.GetUser(); }

    @PopForums.Resources.Login

    @if (!SettingsManager.Current.IsPrivateForumInstance) { }
    d-none}">@Context.Request.Query["error"]
    @if (user == null) { if (Model.Count > 0) {
    @Html.AntiForgeryToken()

    @PopForums.Resources.ExternalLogins

    @foreach (var item in Model) { }

    }

    @PopForums.Resources.NotRegisteredQuestion @PopForums.Resources.CreateAnAccount. @PopForums.Resources.ForgotPasswordQuestion

    } else {

    @PopForums.Resources.LoginAlready

    } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Account/ManagePhotos.cshtml ================================================ @model UserEditPhoto @inject IConfig Config @{ ViewBag.Title = PopForums.Resources.Account + " - " + PopForums.Resources.ManagePhotos; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; }

    @PopForums.Resources.Account - @PopForums.Resources.ManagePhotos

    @PopForums.Resources.Avatar

    @if (Model.AvatarID.HasValue) {

    Avatar image

    }

    @PopForums.Resources.Photo

    @if (Model.ImageID.HasValue) {

    User image

    if (Model.IsImageApproved.HasValue && !Model.IsImageApproved.Value) {

    @PopForums.Resources.PhotoNotApproved

    } }
    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Account/MiniProfile.cshtml ================================================ @model DisplayProfile
    @PopForums.Resources.Joined:
    @if (!String.IsNullOrWhiteSpace(Model.Location)) { @PopForums.Resources.Location: @Model.Location
    } @PopForums.Resources.Posts: @Model.PostCount.ToString("N0"), @PopForums.Resources.ScoringGame: @Model.Points.ToString("N0")
    @if (!String.IsNullOrWhiteSpace(Model.Facebook)) { } @if (!String.IsNullOrWhiteSpace(Model.Instagram)) { } Full Profile @if (Model.ShowDetails) {@:| @PopForums.Resources.SendPM } @if (!String.IsNullOrWhiteSpace(Model.Web)) { | @PopForums.Resources.WebVisit }
    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Account/MiniUserNotFound.cshtml ================================================ 

    @PopForums.Resources.UserNotFound

    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Account/OAuthLogin.cshtml ================================================ @using PopForums @model string @inject ISettingsManager SettingsManager @{ Layout = null; } @Resources.Login

    @SettingsManager.Current.ForumTitle

    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Account/Posts.cshtml ================================================ @model PagedTopicContainer @inject IUserRetrievalShim UserRetrievalShim @{ ViewBag.Title = ViewBag.PostUserName + "'s " + PopForums.Resources.Posts; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; var user = UserRetrievalShim.GetUser(); }

    @ViewBag.PostUserName's @PopForums.Resources.Posts

    @foreach (var topic in Model.Topics) { class="bg-danger" } data-topicid="@topic.TopicID"> }

    @Html.ActionLink(topic.Title, "Topic", "Forum", new { id = topic.UrlName, pageNumber = 1 }, null)

    @PopForums.Resources.StartedBy: @topic.StartedByName @PopForums.Resources.In @Model.ForumTitles[topic.ForumID] | @PopForums.Resources.Views: @topic.ViewCount | @PopForums.Resources.Replies: @topic.ReplyCount | @PopForums.Resources.Last: @PopForums.Resources.By @topic.LastPostName
    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Account/ResetPassword.cshtml ================================================ @model PasswordResetContainer @{ ViewBag.Title = PopForums.Resources.PasswordReset; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; }

    @PopForums.Resources.PasswordReset

    @if (Model.IsValidUser) { using(Html.BeginForm()){
    } } else {

    @PopForums.Resources.PasswordResetLinkInvalid @PopForums.Resources.ForgotPasswordQuestion

    } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Account/ResetPasswordSuccess.cshtml ================================================ @{ ViewBag.Title = PopForums.Resources.PasswordResetSuccess; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; }

    @PopForums.Resources.PasswordResetSuccess

    @PopForums.Resources.PasswordResetNote

    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Account/Security.cshtml ================================================ @model UserEditSecurity @{ ViewBag.Title = PopForums.Resources.Account + " - " + PopForums.Resources.ChangeYourEmailPassword; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; }

    @PopForums.Resources.Account - @PopForums.Resources.ChangeYourEmailPassword

    @PopForums.Resources.ChangePassword

    @if (ViewBag.PasswordResult != null) {@ViewBag.PasswordResult}

    @PopForums.Resources.ChangeEmail

    @if (!Model.IsNewUserApproved) {

    @PopForums.Resources.ChangeEmailConsequence

    }

    @Model.OldEmail

    @if (ViewBag.EmailResult != null) {@ViewBag.EmailResult}
    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Account/Unsubscribe.cshtml ================================================ @{ ViewBag.Title = PopForums.Resources.Unsubscribe; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; }

    @PopForums.Resources.Unsubscribe

    @PopForums.Resources.UnsubscribeNote

    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Account/UnsubscribeFailure.cshtml ================================================ @{ ViewBag.Title = PopForums.Resources.UnsubscribeFail; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; }

    @PopForums.Resources.UnsubscribeFail

    @PopForums.Resources.UnsubscribeLinkBad

    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Account/Verify.cshtml ================================================ @using PopForums.Configuration @inject ISettingsManager SettingsManager @{ ViewBag.Title = PopForums.Resources.VerifyAccount; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; }

    @PopForums.Resources.VerifyAccount

    @if (!SettingsManager.Current.IsPrivateForumInstance) { } @if (ViewData["EmailProblem"] != null) {
    @ViewData["EmailProblem"] @ViewData["Result"]
    } else if (ViewData["Result"] != null) {
    @ViewData["Result"]
    }
    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Account/VerifyFail.cshtml ================================================ @using PopForums.Configuration @inject ISettingsManager SettingsManager @{ ViewBag.Title = PopForums.Resources.VerificationFailure; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; }

    @PopForums.Resources.VerificationFailure

    @if (!SettingsManager.Current.IsPrivateForumInstance) { }

    @PopForums.Resources.VerificationLinkBad @PopForums.Resources.NeedToVerifyExistingAccount

    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Account/ViewProfile.cshtml ================================================ @model DisplayProfile @{ ViewBag.Title = PopForums.Resources.Profile + " - " + Model.Name; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; } @section HeaderContent { } @if (Model.IsApproved) {

    @if (Model.AvatarID.HasValue) { Avatar image } @Model.Name

    @if (Model.ShowDetails) { }
    @PopForums.Resources.Joined
    @if (Model.Dob.HasValue) {
    @PopForums.Resources.Birthday
    @Model.Dob.Value.ToString("D")
    } @if (!String.IsNullOrWhiteSpace(Model.Location)) {
    @PopForums.Resources.Location
    @Model.Location
    } @if (!String.IsNullOrWhiteSpace(Model.Facebook)) {
    Facebook
    } @if (!String.IsNullOrWhiteSpace(Model.Instagram)) {
    Instagram
    } @if (!String.IsNullOrWhiteSpace(Model.Web)) {
    @PopForums.Resources.Web
    }
    @PopForums.Resources.ScoringGame
    @Model.Points.ToString("N0")
    @if (Model.ImageID.HasValue && Model.IsImageApproved) {
    User image
    }
    @foreach (var item in Model.Feed) {
    @if (item.Points > 0) {
    +@item.Points
    }
    @Html.Raw(item.Message)
    }
    @foreach (var item in Model.UserAwards) {

    @item.Title

    @item.Description

    } @if (Model.UserAwards.Count == 0) {
    @PopForums.Resources.None
    }
    } else {

    @PopForums.Resources.Profile

    @PopForums.Resources.AccountNotVerified

    } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Admin/App.cshtml ================================================ @inject IConfig Config @{ Layout = null; var isOAuthOnly = Config.IsOAuthOnly; } @PopForums.Resources.PopForumsAdmin
    ©@DateTime.Now.Year, POP World Media, LLC
    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Favorites/Topics.cshtml ================================================ @model PagedTopicContainer @inject IUserRetrievalShim UserRetrievalShim @{ ViewBag.Title = PopForums.Resources.FavoriteTopics; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; var user = UserRetrievalShim.GetUser(); }

    @PopForums.Resources.FavoriteTopics

    @if (Model == null) {

    @PopForums.Resources.FavoriteMustBeLoggedIn

    } else { if (Model.Topics.Count == 0) {

    @PopForums.Resources.FavoritesDontHave

    } @foreach (var topic in Model.Topics) { class="bg-warning" }> }

    @topic.Title

    @PopForums.Resources.StartedBy: @topic.StartedByName @PopForums.Resources.In @Model.ForumTitles[topic.ForumID] | @PopForums.Resources.Views: @topic.ViewCount | @PopForums.Resources.Replies: @topic.ReplyCount | @PopForums.Resources.Last: @PopForums.Resources.By @topic.LastPostName
    } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Forum/Edit.cshtml ================================================ @model PostEdit @{ ViewBag.Title = PopForums.Resources.EditPost; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; var message = ViewBag.Message; } @section HeaderContent { }

    @PopForums.Resources.EditPost

    @if (Model.IsFirstInTopic) {
    }
    @if (message != null) {@message}
    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Forum/Index.cshtml ================================================ @inject IUserRetrievalShim UserRetrievalShim @model ForumTopicContainer @{ ViewBag.Title = Model.Forum.Title; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; var user = UserRetrievalShim.GetUser(); var forumStateSerialized = JsonSerializer.Serialize(Model.ForumState, new JsonSerializerOptions{ PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); } @section HeaderContent{ @if (user != null) { } }

    @Model.Forum.Title@if (Model.PermissionContext.UserCanPost){ }

    @if (Model.PermissionContext.UserCanPost) { } else {

    @Model.PermissionContext.DenialReason

    } @if (user != null) {
    } @foreach (var topic in Model.Topics) { class="bg-warning" }data-topicid="@topic.TopicID"> }
    @if (user == null) { } else { }

    @topic.Title

    @PopForums.Resources.StartedBy: @topic.StartedByName | @PopForums.Resources.Views: @topic.ViewCount.ToString("N0") | @PopForums.Resources.Replies: @topic.ReplyCount.ToString("N0") | @PopForums.Resources.Last: @PopForums.Resources.By @topic.LastPostName
    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Forum/IndexQA.cshtml ================================================ @inject IUserRetrievalShim UserRetrievalShim @model ForumTopicContainer @{ ViewBag.Title = Model.Forum.Title; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; var user = UserRetrievalShim.GetUser(); var forumStateSerialized = JsonSerializer.Serialize(Model.ForumState, new JsonSerializerOptions{ PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); } @section HeaderContent{ @if (user != null) { } }

    @Model.Forum.Title@if (Model.PermissionContext.UserCanPost){ }

    @if (Model.PermissionContext.UserCanPost) { } else {

    @Model.PermissionContext.DenialReason

    } @if (user != null) {
    } @foreach (var topic in Model.Topics) { class="bg-warning" } data-topicid="@topic.TopicID"> }
    @if (topic.AnswerPostID.HasValue) { } else { }

    @topic.Title

    @PopForums.Resources.StartedBy: @topic.StartedByName | @PopForums.Resources.Views: @topic.ViewCount.ToString("N0") | @PopForums.Resources.Replies: @topic.ReplyCount.ToString("N0") | @PopForums.Resources.Last: @PopForums.Resources.By @topic.LastPostName
    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Forum/ModeratorPanel.cshtml ================================================ @model Topic @inject IUserRetrievalShim UserRetrievalShim @{ var user = UserRetrievalShim.GetUser(); } @if (user != null && user.IsInRole(PermanentRoles.Moderator)) {

    @PopForums.Resources.Moderator

    @Html.CheckBox("CloseOnReply", new { @class = "form-check-input" })
    • @if (user.IsInRole(PermanentRoles.Admin)) {
    • }
    @Html.Hidden("TopicID", Model.TopicID)
    @Html.TextBox("NewTitle", Model.Title, new {@class = "form-control"})
    } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Forum/NewComment.cshtml ================================================ @model NewPost ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Forum/NewReply.cshtml ================================================ @model NewPost @{ var subhead = PopForums.Resources.CreateNewReply; var replyButtonText = PopForums.Resources.SubmitReply; if (ViewBag.IsQA != null && (bool)ViewBag.IsQA) { subhead = PopForums.Resources.PostAnswer; replyButtonText = PopForums.Resources.SubmitAnswer; } } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Forum/NewTopic.cshtml ================================================ @model NewPost ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Forum/PostItem.cshtml ================================================ @inject IUserRetrievalShim UserRetrievalShim @using PopForums @model PostItemContainer @{ var user = UserRetrievalShim.GetUser(); string deleteLink; if (Model.Post.IsDeleted) { deleteLink = PopForums.Resources.Undelete; } else if (Model.Post.IsFirstInTopic) { deleteLink = PopForums.Resources.DeleteTopic; } else { deleteLink = PopForums.Resources.Delete; } var hideVanity = false; if (Model.Profile != null) { hideVanity = Model.Profile.HideVanity; } var isLoggedIn = user != null; var isAuthor = user?.UserID == Model.Post.UserID; var isVoted = isLoggedIn && !isAuthor && Model.VotedPostIDs.Contains(Model.Post.PostID); var isIgnored = Model.IgnoreUserIDs.Contains(Model.Post.UserID); }
    @if (isIgnored) { }
    @if (Model.User != null && Model.User.IsInRole(PermanentRoles.Moderator)) { @:IP: @Model.Post.IP - }
    @if (!hideVanity && Model.Avatars.ContainsKey(Model.Post.UserID)) { @String.Format(PopForums.Resources.NameAvatar, Model.Post.Name) }
    @Html.Raw(Model.Post.FullText)
    @if (Model.Post.IsEdited && Model.Post.LastEditTime.HasValue) { @String.Format(PopForums.Resources.NameLastEdit, Model.Post.LastEditName), } @if (Model.Post.ShowSig && !hideVanity && (Model.Signatures).ContainsKey(Model.Post.UserID)) {
    @Html.Raw((Model.Signatures)[Model.Post.UserID])
    }
    @if (user != null && Model.Topic != null && Model.Topic.IsClosed == false) {
    } @if (Model.User.IsPostEditable(Model.Post)) {
    }
    @if (user != null) { @if (isIgnored) {
    } else {
    } } @if (Model.User.IsPostEditable(Model.Post)) { @if (Model.Post.IsDeleted) {
    } else {
    } }
    @if (Model.Post.IsEdited && Model.User != null && Model.User.IsInRole(PermanentRoles.Moderator)) {
    }
    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Forum/QAPost.cshtml ================================================ @model QAPostItemContainer @inject IUserRetrievalShim UserRetrievalShim @{ var user = UserRetrievalShim.GetUser(); var profile = UserRetrievalShim.GetProfile(); var hideVanity = false; if (profile != null) { hideVanity = profile.HideVanity; } string deleteLink; if (Model.Post.IsDeleted) { deleteLink = PopForums.Resources.Undelete; } else if (Model.Post.IsFirstInTopic) { deleteLink = PopForums.Resources.DeleteTopic; } else { deleteLink = PopForums.Resources.Delete; } var isLoggedIn = user != null; var isAuthor = user?.UserID == Model.Post.UserID; var isVoted = isLoggedIn && !isAuthor && Model.VotedPostIDs.Contains(Model.Post.PostID); }
    @if (user != null && user.IsInRole(PermanentRoles.Moderator)) { @:IP: @Model.Post.IP - }
    @if (!hideVanity && Model.Avatars.ContainsKey(Model.Post.UserID)) { @String.Format(PopForums.Resources.NameAvatar, Model.Post.Name) }
    @Html.Raw(Model.Post.FullText)
    @if (Model.Post.IsEdited && Model.Post.LastEditTime.HasValue) { @String.Format(PopForums.Resources.NameLastEdit, Model.Post.LastEditName), } @if (Model.Post.ShowSig && !hideVanity && (Model.Signatures).ContainsKey(Model.Post.UserID)) {
    @Html.Raw((Model.Signatures)[Model.Post.UserID])
    }
    @if (user != null && Model.Topic != null && Model.Topic.IsClosed == false) {
    } @if (user.IsPostEditable(Model.Post)) {
    } @if (Model.User.IsPostEditable(Model.Post)) { @if (Model.Post.IsDeleted) {
    } else {
    } }
    @if (Model.Post.IsEdited && user != null && user.IsInRole(PermanentRoles.Moderator)) {
    }
    @if (Model.PostWithChildren.Children != null && Model.PostWithChildren.Children.Count > 0) { foreach (var comment in Model.PostWithChildren.Children) {

    @Html.ActionLink(comment.Name, "ViewProfile", AccountController.Name, new { id = comment.UserID }, null) - @if (user != null && user.IsInRole(PermanentRoles.Moderator)) { - IP: @comment.IP}

    @Html.Raw(comment.FullText) @if (user.IsPostEditable(comment)) { if (comment.IsDeleted) {
    } else {
    } }
    } if (user != null && Model.PostWithChildren.Children.Count > 0 && !Model.Topic.IsClosed) {

    } }
    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Forum/Recent.cshtml ================================================ @model PagedTopicContainer @inject IUserRetrievalShim UserRetrievalShim @{ ViewBag.Title = PopForums.Resources.RecentTopics; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; var user = UserRetrievalShim.GetUser(); var forumStateSerialized = JsonSerializer.Serialize((ForumState) ViewBag.ForumState, new JsonSerializerOptions{ PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); } @section HeaderContent { }

    @PopForums.Resources.Recent

    @foreach (var topic in Model.Topics) { class="bg-warning" }data-topicid="@topic.TopicID"> }
    @if (user == null) { } else { }

    @Html.ActionLink(topic.Title, "Topic", "Forum", new { id = topic.UrlName, pageNumber = 1 }, null)

    @PopForums.Resources.StartedBy: @topic.StartedByName @PopForums.Resources.In @Model.ForumTitles[topic.ForumID] | @PopForums.Resources.Views: @topic.ViewCount | @PopForums.Resources.Replies: @topic.ReplyCount | @PopForums.Resources.Last: @PopForums.Resources.By @topic.LastPostName
    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Forum/Topic.cshtml ================================================ @model TopicContainer @inject IUserRetrievalShim UserRetrievalShim @{ ViewBag.Title = Model.Topic.Title; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; var user = UserRetrievalShim.GetUser(); var profile = UserRetrievalShim.GetProfile(); var topicStateSerialized = JsonSerializer.Serialize(Model.TopicState, new JsonSerializerOptions{ PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); } @section HeaderContent { }

    @if (user != null) { } @Model.Topic.Title

    @if (user != null) {

    } @if (Model.PagerContext.PageIndex > 1) { }
    @foreach(var post in Model.Posts) { @await Html.PartialAsync("~/Areas/Forums/Views/Forum/PostItem.cshtml", new PostItemContainer { Post = post, VotedPostIDs = Model.VotedPostIDs, Signatures = Model.Signatures, Avatars = Model.Avatars, User = user, Profile = profile, Topic = Model.Topic, IgnoreUserIDs = Model.IgnoreUserIDs }); }

    @if (Model.PermissionContext.UserCanPost) {
    } else {

    @Model.PermissionContext.DenialReason

    } @await Html.PartialAsync("ModeratorPanel", Model.Topic) ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Forum/TopicPage.cshtml ================================================ @model TopicContainer @inject IUserRetrievalShim UserRetrievalShim @{ var user = UserRetrievalShim.GetUser(); var profile = UserRetrievalShim.GetProfile(); var routeParameters = new Dictionary {{"id", Model.Topic.UrlName}}; }
    @foreach (var post in Model.Posts) { @await Html.PartialAsync("~/Areas/Forums/Views/Forum/PostItem.cshtml", new PostItemContainer { Post = post, VotedPostIDs = Model.VotedPostIDs, Signatures = Model.Signatures, Avatars = Model.Avatars, User = user, Profile = profile, Topic = Model.Topic, IgnoreUserIDs = Model.IgnoreUserIDs}); } @Html.Hidden("LastPostID", Model.Posts.Last().PostID, new { @class = "lastPostID" }) @Html.Hidden("PageCount", Model.PagerContext.PageCount, new { @class = "pageCount" })
    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Forum/TopicQA.cshtml ================================================ @model TopicContainerForQA @inject IUserRetrievalShim UserRetrievalShim @{ ViewBag.Title = Model.Topic.Title; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; var user = UserRetrievalShim.GetUser(); var profile = UserRetrievalShim.GetProfile(); var topicStateSerialized = JsonSerializer.Serialize(Model.TopicState, new JsonSerializerOptions{ PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); } @section HeaderContent { }

    @if (user != null) { } @Model.Topic.Title

    @if (user != null) {

    }
    @{ @(await Html.PartialAsync("QAPost", new QAPostItemContainer { PostWithChildren = Model.QuestionPostWithComments, Topic = Model.Topic, Avatars = Model.Avatars, Signatures = Model.Signatures, User = user, Profile = profile, VotedPostIDs = Model.VotedPostIDs, Post = Model.QuestionPostWithComments.Post })) } @if (Model.PermissionContext.UserCanPost) { } else {

    @Model.PermissionContext.DenialReason

    }

    @PopForums.Resources.Answers

    @foreach (var answer in Model.AnswersWithComments) { @await Html.PartialAsync("QAPost", new QAPostItemContainer { PostWithChildren = answer, Topic = Model.Topic, Avatars = Model.Avatars, Signatures = Model.Signatures, User = user, Profile = profile, VotedPostIDs = Model.VotedPostIDs, Post = answer.Post }); }
    @await Html.PartialAsync("ModeratorPanel", Model.Topic) ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Forum/Voters.cshtml ================================================ @model VotePostContainer
      @foreach (var user in Model.Voters) {
    • @Html.ActionLink(user.Value, "ViewProfile", "Account", new { id = user.Key }, new { target = "_blank" })
    • }
    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Home/Index.cshtml ================================================ @inject IUserRetrievalShim UserRetrievalShim @model CategorizedForumContainer @{ ViewBag.Title = Model.ForumTitle; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; var user = UserRetrievalShim.GetUser(); } @section HeaderContent{ }

    @Model.ForumTitle

    @if (user != null) { @if (!user.IsApproved) {

    @PopForums.Resources.VerifyAccount

    }
    } @foreach (var forum in Model.UncategorizedForums) { } @foreach (var category in Model.CategoryDictionary) { foreach (var forum in category.Value) { } }

    @forum.Title

    @forum.Description

    @PopForums.Resources.Topics: @forum.TopicCount.ToString("N0") | @PopForums.Resources.Posts: @forum.PostCount.ToString("N0") | @PopForums.Resources.Last: @PopForums.Resources.By @forum.LastPostName
    @category.Key.Title

    @forum.Title

    @forum.Description

    @PopForums.Resources.Topics: @forum.TopicCount.ToString("N0") | @PopForums.Resources.Posts: @forum.PostCount.ToString("N0") | @PopForums.Resources.Last: @PopForums.Resources.By @forum.LastPostName

    @PopForums.Resources.UsersOnline

    @PopForums.Resources.Total: @ViewBag.TotalUsers@foreach (var u in (List)ViewBag.OnlineUsers) {, @u.Name}

    @PopForums.Resources.TotalTopics: @ViewBag.TopicCount, @PopForums.Resources.TotalPosts: @ViewBag.PostCount, @PopForums.Resources.RegisteredUsers: @ViewBag.RegisteredUsers

    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Identity/ExternalError.cshtml ================================================ @model string @{ ViewBag.Title = PopForums.Resources.Login + " - " + PopForums.Resources.Error; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; }

    @PopForums.Resources.Login - @PopForums.Resources.Error

    @PopForums.Resources.Error: @Model

    @PopForums.Resources.Login

    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Identity/ExternalLoginCallback.cshtml ================================================ @{ ViewBag.Title = @PopForums.Resources.Login; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; }

    @PopForums.Resources.Login - @PopForums.Resources.ExternalLogins

    @PopForums.Resources.UseExistingForumAccount

    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Ignore/List.cshtml ================================================ @model IEnumerable @inject IUserRetrievalShim UserRetrievalShim @{ ViewBag.Title = PopForums.Resources.IgnoreList; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; var user = UserRetrievalShim.GetUser(); }

    @PopForums.Resources.IgnoreList

    @if (user == null) {

    @PopForums.Resources.NotLoggedIn

    } else { if (Model == null || !Model.Any()) {

    @PopForums.Resources.NoResults

    } @foreach (var name in Model) { }
    @name.Name
    } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Moderator/PostModerationLog.cshtml ================================================ @model List
    @foreach (var entry in Model) { }
    @entry.UserName @entry.ModerationType

    @PopForums.Resources.Comment: @entry.Comment

    @Html.Raw(entry.OldText)
    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Moderator/TopicModerationLog.cshtml ================================================ @model List

    @PopForums.Resources.ModerationLog

    @if (Model.Count == 0) {

    @PopForums.Resources.None

    } @foreach (var entry in Model) { @if (entry.UserID == 0) { } else { } }
    @entry.UserName@entry.UserName@entry.ModerationType
    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/PrivateMessages/Archive.cshtml ================================================ @inject IUserRetrievalShim UserRetrievalShim @model List @{ var user = UserRetrievalShim.GetUser(); ViewBag.Title = PopForums.Resources.PrivateMessages + " - " + PopForums.Resources.Archived; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; } @foreach (var pm in Model) { }

    @PrivateMessage.GetUserNames(pm, user.UserID)

    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/PrivateMessages/Create.cshtml ================================================ @{ ViewBag.Title = PopForums.Resources.NewPM; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; } @section HeaderContent { } @if (ViewBag.Warning != null) {

    @ViewBag.Warning

    }
    @if (ViewBag.TargetUserID != null) {
    @ViewBag.TargetUserName
    }
    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/PrivateMessages/Index.cshtml ================================================ @inject IUserRetrievalShim UserRetrievalShim @model List @{ var user = UserRetrievalShim.GetUser(); ViewBag.Title = PopForums.Resources.PrivateMessages; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; }

    @PopForums.Resources.PrivateMessages

    @PopForums.Resources.SendPM

    @foreach (var pm in Model) { }

    @PrivateMessage.GetUserNames(pm, user.UserID)

    @PopForums.Resources.ViewArchivedMessages

    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/PrivateMessages/View.cshtml ================================================ @inject IUserRetrievalShim UserRetrievalShim @using PopForums @model PrivateMessageView @{ var user = UserRetrievalShim.GetUser(); var title = PrivateMessage.GetUserNames(Model.PrivateMessage, user.UserID); ViewBag.Title = PopForums.Resources.PrivateMessages + " - " + title; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; var pmSerialized = JsonSerializer.Serialize(Model.State, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); } @section HeaderContent { }
    @if (Model.State.IsUserNotFound) {

    @Resources.UserNotFound

    } else { }

    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Search/Index.cshtml ================================================ @model PagedTopicContainer @inject IUserRetrievalShim UserRetrievalShim @{ ViewBag.Title = PopForums.Resources.Search; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; var routeParameters = new Dictionary { { "query", ViewBag.Query }, { "searchType", ViewBag.SearchType } }; var user = UserRetrievalShim.GetUser(); }

    @PopForums.Resources.Search

    @Html.DropDownList("SearchType", (SelectList)ViewBag.SearchTypes, new { @class = "form-select me-1 " })
    @if ((bool) ViewBag.IsError) {
    @PopForums.Resources.SearchError
    } @if (Model.Topics.Count == 0 && !String.IsNullOrEmpty(ViewBag.Query)) {

    @PopForums.Resources.NoResults

    } else { @foreach (var topic in Model.Topics) { class="bg-warning" }> }
    @if (user == null) { } else { }

    @Html.ActionLink(topic.Title, "Topic", "Forum", new { id = topic.UrlName, pageNumber = 1 }, null)

    @PopForums.Resources.StartedBy: @topic.StartedByName @PopForums.Resources.In @Model.ForumTitles[topic.ForumID] | @PopForums.Resources.Views: @topic.ViewCount | @PopForums.Resources.Replies: @topic.ReplyCount | @PopForums.Resources.Last: @PopForums.Resources.By @topic.LastPostName
    } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Setup/Exception.cshtml ================================================ @model System.Exception @PopForums.Resources.PopForumsSetup - @PopForums.Resources.Error

    @PopForums.Resources.ErrorSettingUpDb:

    @Model.Message

    @Html.Raw(Model.StackTrace.Replace(Environment.NewLine, "
    "))

    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Setup/Index.cshtml ================================================ @model SetupVariables @PopForums.Resources.PopForumsSetup

    @PopForums.Resources.PopForumsSetup

    @PopForums.Resources.SetupConnSuccess:

    @using (Html.BeginForm("Index", "Setup", FormMethod.Post, new { @class = "form-horizontal", role = "form" })) {
    @Html.TextBoxFor(s => s.ForumTitle, new { @class = "form-control" })
    @Html.TextBoxFor(s => s.SmtpServer, new { @class = "form-control" })
    @Html.TextBoxFor(s => s.SmtpPort, new { @class = "form-control" })
    @Html.TextBoxFor(s => s.MailerAddress, new { @class = "form-control" })
    @Html.CheckBoxFor(s => s.UseSslSmtp, new { @class = "form-check-input" })
    @Html.CheckBoxFor(s => s.UseEsmtp, new { @class = "form-check-input" })
    @Html.TextBoxFor(s => s.SmtpUser, new { @class = "form-control" })
    @Html.TextBoxFor(s => s.SmtpPassword, new { @class = "form-control" })

    @PopForums.Resources.SetupFirstUser

    @Html.TextBoxFor(s => s.Name, new { @class = "form-control" })
    @Html.TextBoxFor(s => s.Email, new { @class = "form-control" })
    @Html.PasswordFor(s => s.Password, new { @class = "form-control" })
    }
    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Setup/NoConnection.cshtml ================================================  @PopForums.Resources.PopForumsSetup - @PopForums.Resources.NoDataConnection

    @PopForums.Resources.PopForumsSetup

    @PopForums.Resources.SetupCantConnect

    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Setup/Success.cshtml ================================================  @PopForums.Resources.PopForumsSetup - @PopForums.Resources.Success

    @PopForums.Resources.PopForumsSetup - @PopForums.Resources.Success

    Congratulations!

    @PopForums.Resources.ForumReady

    @PopForums.Resources.AppRestartRequired

    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Shared/Components/UserNavigation/Default.cshtml ================================================ @using PopForums.Configuration @inject IUserRetrievalShim UserRetrievalShim @inject ISettingsManager SettingsManager @inject IConfig Config @{ var user = UserRetrievalShim.GetUser(); } @if (user != null) {
    } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Shared/Components/UserState/Default.cshtml ================================================ @model UserState @inject IUserRetrievalShim UserRetrievalShim @inject ITimeFormatStringService TimeFormatStringService @{ var serialized = JsonSerializer.Serialize(Model, new JsonSerializerOptions{ PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); var user = UserRetrievalShim.GetUser(); } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Shared/Forbidden.cshtml ================================================ @{ ViewBag.Title = PopForums.Resources.Forbidden; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; }

    @PopForums.Resources.Forbidden

    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Shared/NotFound.cshtml ================================================ @{ ViewBag.Title = PopForums.Resources.PageNotFound; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; }

    @PopForums.Resources.PageNotFound

    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Shared/PopForumsMaster.cshtml ================================================ @inject IConfig Config @{ Layout = "~/Views/Shared/_Layout.cshtml"; } @section HeaderContent { @if (Config.RenderBootstrap) { } @if (Config.RenderBootstrap) { } @await RenderSectionAsync("HeaderContent", required: false) @await Component.InvokeAsync("UserState") }
    @await Component.InvokeAsync("UserNavigation")
    @RenderBody()
    POP Forums - ©@DateTime.Now.Year, POP World Media, LLC
    ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/Subscription/Topics.cshtml ================================================ @model PagedTopicContainer @inject IUserRetrievalShim UserRetrievalShim @{ ViewBag.Title = PopForums.Resources.SubscribedTopics; Layout = "~/Areas/Forums/Views/Shared/PopForumsMaster.cshtml"; var user = UserRetrievalShim.GetUser(); }

    @PopForums.Resources.SubscribedTopics

    @if (Model == null) {

    @PopForums.Resources.SubscribeLoggedIn

    } else { if (Model.Topics.Count == 0) {

    @PopForums.Resources.SubscribeNone

    } @foreach (var topic in Model.Topics) { class="bg-warning" }> }

    @topic.Title

    @PopForums.Resources.StartedBy: @topic.StartedByName @PopForums.Resources.In @Model.ForumTitles[topic.ForumID] | @PopForums.Resources.Views: @topic.ViewCount | @PopForums.Resources.Replies: @topic.ReplyCount | @PopForums.Resources.Last: @PopForums.Resources.By @topic.LastPostName
    } ================================================ FILE: src/PopForums.Mvc/Areas/Forums/Views/_ViewImports.cshtml ================================================ @using PopForums.Extensions @using PopForums.Models @using PopForums.Services @using PopForums.Mvc.Areas.Forums.Controllers @using PopForums.Mvc.Areas.Forums.Models @using PopForums.Mvc.Areas.Forums.Services @using System.Threading.Tasks @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers @addTagHelper *, PopForums.Mvc ================================================ FILE: src/PopForums.Mvc/Client/Components/AnswerButton.ts ================================================ namespace PopForums { export class AnswerButton extends ElementBase { constructor() { super(); } get answerstatusclass(): string { return this.getAttribute("answerstatusclass")!; } get chooseanswertext(): string { return this.getAttribute("chooseanswertext")!; } get topicid(): string { return this.getAttribute("topicid")!; } get postid(): string { return this.getAttribute("postid")!; } get answerpostid(): string { return this.getAttribute("answerpostid")!; } get userid(): string { return this.getAttribute("userid")!; } get startedbyuserid(): string { return this.getAttribute("startedbyuserid")!; } get isfirstintopic(): string { return this.getAttribute("isfirstintopic")!; } private button!: HTMLElement; connectedCallback() { this.button = document.createElement("p"); this.button.classList.add("icon"); this.answerstatusclass.split(" ").forEach((c) => this.button.classList.add(c)); if (this.isfirstintopic.toLowerCase() === "false" && this.userid === this.startedbyuserid) { // make it a button for author this.button.addEventListener("click", () => { PopForums.currentTopicState.setAnswer(Number(this.postid), Number(this.topicid)); }); } this.appendChild(this.button); super.connectedCallback(); } getDependentReference(): [TopicState, string] { return [PopForums.currentTopicState, "answerPostID"]; } updateUI(answerPostID: number): void { if (this.isfirstintopic.toLowerCase() === "false" && this.userid === this.startedbyuserid) { // this is question author this.button.classList.add("asnswerButton", "fs-1", "my-3"); if (answerPostID && this.postid === answerPostID.toString()) { this.button.classList.remove("icon-check-circle"); this.button.classList.remove("text-muted"); this.button.classList.add("icon-check-circle-fill"); this.button.classList.add("text-success"); this.style.cursor = "default"; } else { this.button.classList.remove("icon-check-circle-fill"); this.button.classList.remove("text-success"); this.button.classList.add("icon-check-circle"); this.button.classList.add("text-muted"); this.style.cursor = "pointer"; } } else if (answerPostID && this.postid === answerPostID.toString()) { // not the question author, but it is the answer this.button.classList.add("icon-check-circle-fill"); this.button.classList.add("text-success"); this.style.cursor = "default"; } } } customElements.define('pf-answerbutton', AnswerButton); } ================================================ FILE: src/PopForums.Mvc/Client/Components/CommentButton.ts ================================================ namespace PopForums { export class CommentButton extends ElementBase { constructor() { super(); } get buttonclass(): string { return this.getAttribute("buttonclass")!; } get buttontext(): string { return this.getAttribute("buttontext")!; } get topicid(): string { return this.getAttribute("topicid")!; } get postid(): string { return this.getAttribute("postid")!; } connectedCallback() { this.innerHTML = CommentButton.template; let button = this.querySelector("button") as HTMLButtonElement; button.title = this.buttontext; if (this.buttonclass?.length > 0) this.buttonclass.split(" ").forEach((c) => button.classList.add(c)); if (button.classList.contains("btn")) button.innerText = this.buttontext; button.addEventListener("click", (e: MouseEvent) => { PopForums.currentTopicState.loadComment(Number(this.topicid), Number(this.postid)); }); super.connectedCallback(); } getDependentReference(): [TopicState, string] { return [PopForums.currentTopicState, "commentReplyID"]; } updateUI(data: number): void { let button = this.querySelector("button")!; if (data !== undefined) { button.disabled = true; button.style.cursor = "default"; } else button.disabled = false; } static template: string = ``; } customElements.define('pf-commentbutton', CommentButton); } ================================================ FILE: src/PopForums.Mvc/Client/Components/FavoriteButton.ts ================================================ namespace PopForums { export class FavoriteButton extends ElementBase { constructor() { super(); } get buttonclass(): string { return this.getAttribute("buttonclass")!; } get makefavoritetext(): string { return this.getAttribute("makefavoritetext")!; } get removefavoritetext(): string { return this.getAttribute("removefavoritetext")!; } connectedCallback() { this.innerHTML = SubscribeButton.template; let button: HTMLButtonElement = this.querySelector("button")!; this.buttonclass.split(" ").forEach((c) => button.classList.add(c)); button.addEventListener("click", () => { fetch(PopForums.AreaPath + "/Favorites/ToggleFavorite/" + PopForums.currentTopicState.topicID, { method: "POST" }) .then(response => response.json()) .then(result => { switch (result.data.isFavorite) { case true: PopForums.currentTopicState.isFavorite = true; break; case false: PopForums.currentTopicState.isFavorite = false; break; default: // TODO: something else } }) .catch(() => { // TODO: handle error }); }); super.connectedCallback(); } getDependentReference(): [TopicState, string] { return [PopForums.currentTopicState, "isFavorite"]; } updateUI(data: boolean): void { let button = this.querySelector("button")!; if (data) { button.title = this.removefavoritetext; button.classList.remove("icon-star", "text-muted"); button.classList.add("icon-star-fill", "text-warning"); } else { button.title = this.makefavoritetext; button.classList.remove("icon-star-fill", "text-warning"); button.classList.add("icon-star", "text-muted"); } } static template: string = ``; } customElements.define('pf-favoritebutton', FavoriteButton); } ================================================ FILE: src/PopForums.Mvc/Client/Components/FormattedTime.ts ================================================ namespace PopForums { export class FormattedTime extends HTMLElement { constructor() { super(); } get utctime(): string { return this.getAttribute("utctime")!; } private utcTime!: number; private utcTimeAsDate!: Date; private static dayInMs = 86400000; private isReady!: boolean; connectedCallback() { const delegate = this.ready.bind(this); this.isReady = LocalizationService.subscribe(delegate); if (this.isReady) this.ready(); } ready() { this.setBaseTime(); let now = Date.now(); let yesterdayMs = now - FormattedTime.dayInMs; let yesterdayTemp = new Date(yesterdayMs); let yesterday = new Date(yesterdayTemp.getFullYear(), yesterdayTemp.getMonth(), yesterdayTemp.getDate()); this.innerHTML = this.GetDisplayTime(); if (this.utcTime > yesterday.getTime()) this.UpdateTimer(); this.isReady = true; } private setBaseTime() { let baseTime = this.utctime; if (!baseTime.endsWith("Z")) baseTime = baseTime + "Z"; this.utcTime = Date.parse(baseTime); this.utcTimeAsDate = new Date(baseTime); } private UpdateTimer(): void { setTimeout(() => { this.UpdateTimer(); this.innerHTML = this.GetDisplayTime(); }, 60000); } private GetDisplayTime(): string { let now = Date.now(); let nowAsDate = new Date(); let diff = now - this.utcTime; let yesterdayMs = now - 86400000; let yesterdayTemp = new Date(yesterdayMs); let yesterday = new Date(yesterdayTemp.getFullYear(), yesterdayTemp.getMonth(), yesterdayTemp.getDate()); const dateOptions: Intl.DateTimeFormatOptions = { weekday: "long", year: "numeric", month: "long", day: "numeric" }; const timeOptions: Intl.DateTimeFormatOptions = { hour: "numeric", minute: "2-digit" }; if (diff > 3599000) { // more than an hour if (this.utcTimeAsDate.toLocaleDateString() === nowAsDate.toLocaleDateString()) return PopForums.localizations.todayTime.replace("{0}", this.utcTimeAsDate.toLocaleTimeString(undefined, timeOptions)); if (this.utcTimeAsDate.toLocaleDateString() === yesterday.toLocaleDateString()) return PopForums.localizations.yesterdayTime.replace("{0}", this.utcTimeAsDate.toLocaleTimeString(undefined, timeOptions)); return this.utcTimeAsDate.toLocaleDateString(undefined, dateOptions) + " " + this.utcTimeAsDate.toLocaleTimeString(undefined, timeOptions); } if (diff > 120000) return PopForums.localizations.minutesAgo.replace("{0}", Math.round(diff / 60000).toString()); if (diff > 60000) return PopForums.localizations.oneMinuteAgo; return PopForums.localizations.lessThanMinute; } static get observedAttributes() { return ["utctime"]; } attributeChangedCallback(name: string, oldValue: string, newValue: string) { if (name === "utctime" && this.isReady) { this.setBaseTime(); this.innerHTML = this.GetDisplayTime(); this.UpdateTimer(); } } } customElements.define('pf-formattedtime', FormattedTime); } ================================================ FILE: src/PopForums.Mvc/Client/Components/FullText.ts ================================================ namespace PopForums { export class FullText extends ElementBase { constructor() { super(); } get overridelistener(): string { return this.getAttribute("overridelistener")!; } get forcenoimage(): string { return this.getAttribute("forcenoimage")!; } get isshort(): string { return this.getAttribute("isshort")!; } get formID() { return this.getAttribute("formid") }; get value() { return this._value;} set value(v: string) { this._value = v; } _value!: string; static formAssociated = true; private textBox!: HTMLElement; private externalFormElement!: HTMLElement; connectedCallback() { var initialValue = this.getAttribute("value"); if (initialValue) this.value = initialValue; if (userState.isPlainText) { this.externalFormElement = document.createElement("textarea"); this.externalFormElement.id = this.formID!; this.externalFormElement.setAttribute("name", this.formID!); this.externalFormElement.classList.add("form-control"); if (this.value) (this.externalFormElement as HTMLTextAreaElement).value = this.value; (this.externalFormElement as HTMLTextAreaElement).rows = 12; if (this.isshort?.toLowerCase() === "true") (this.externalFormElement as HTMLTextAreaElement).rows = 3; let self = this; this.externalFormElement.addEventListener("change", () => { self.value = (this.externalFormElement as HTMLTextAreaElement).value; }); this.appendChild(this.externalFormElement); if (this.overridelistener?.toLowerCase() !== "true") super.connectedCallback(); return; } let template = document.createElement("template"); template.innerHTML = FullText.template; this.attachShadow({ mode: "open" }); this.shadowRoot!.append(template.content.cloneNode(true)); this.textBox = this.shadowRoot!.querySelector("#editor")!; if (this.value) (this.textBox as HTMLTextAreaElement).innerText = this.value; this.editorSettings.target = this.textBox; if (!userState.isImageEnabled || this.forcenoimage?.toLowerCase() === "true") this.editorSettings.toolbar = FullText.postNoImageToolbar; if (this.isshort?.toLowerCase() === "true") this.editorSettings.height = "200"; var self = this; this.editorSettings.setup = function (editor: any) { editor.on("init", function (this: any) { this.on("focusout", function(e: any) { editor.save(); self.value = (self.textBox as HTMLInputElement).value; (self.externalFormElement as any).value = self.value; }); this.on("blur", function(e: any) { editor.save(); self.value = (self.textBox as HTMLInputElement).value; (self.externalFormElement as any).value = self.value; }); editor.save(); self.value = (self.textBox as HTMLInputElement).value; (self.externalFormElement as any).value = self.value; }); function InstantImageUpload() { const input = document.createElement("input"); input.setAttribute("type", "file"); input.setAttribute("accept", "image/jpeg,image/gif,image/png"); input.addEventListener("change", (e) => { const file = input.files![0]; let url = "/Forums/Image/UploadPostImage"; let form = new FormData(); form.append("file", file); editor.setProgressState(true); fetch(url, { method: "POST", body: form }) .then(response => { if (response.ok) return response.json(); alert("Could not upload image: " + response.status); }) .then(payload => { editor.insertContent(``); PopForums.userState.postImageIds.push(payload.id); }) .catch(error => { alert("Could not upload image: " + error); }) .finally(() => { editor.setProgressState(false); input.remove(); }); }); input.style.display = "none"; document.getElementById("ForumContainer")!.append(input); input.click(); }; editor.ui.registry.addButton("imageup", { icon: "upload", tooltip: "Upload Image", onAction: () => InstantImageUpload() }); }; tinymce.init(this.editorSettings); this.externalFormElement = document.createElement("input") as HTMLInputElement; this.externalFormElement.id = this.formID!; this.externalFormElement.setAttribute("name", this.formID!); (this.externalFormElement as HTMLInputElement).type = "hidden"; this.appendChild(this.externalFormElement); if (this.overridelistener?.toLowerCase() !== "true") super.connectedCallback(); } getDependentReference(): [TopicState, string] { return [PopForums.currentTopicState, "nextQuote"]; } updateUI(data: any): void { if (data !== null && data !== undefined) { if (userState.isPlainText) { (this.externalFormElement as HTMLTextAreaElement).value += data; this.value = (this.externalFormElement as HTMLTextAreaElement).value; } else { let editor = tinymce.get("editor"); var content = editor.getContent(); content += data; editor.setContent(content); (this.textBox as HTMLInputElement).value += content; editor.save(); this.value = (this.textBox as HTMLInputElement).value; (this.externalFormElement as HTMLInputElement).value = this.value; } } } private static editorCSS = "/PopForums/lib/bootstrap/dist/css/bootstrap.min.css,/PopForums/lib/PopForums/dist/Editor.min.css"; private static postNoImageToolbar = "bold italic | bullist numlist blockquote removeformat | link"; editorSettings = { license_key: 'gpl', target: null as unknown as HTMLElement, plugins: "lists image link", content_css: FullText.editorCSS, menubar: false, toolbar: "bold italic | bullist numlist blockquote removeformat | link | image imageup", statusbar: false, link_target_list: false, link_title: false, image_description: false, image_dimensions: false, image_title: false, image_uploadtab: false, images_file_types: 'jpeg,jpg,png,gif', automatic_uploads: false, browser_spellcheck : true, object_resizing: false, relative_urls: false, remove_script_host: false, contextmenu: "", paste_as_text: true, paste_data_images: false, setup: null as unknown as Function, height: null as unknown as string }; static id: string = "FullText"; static template: string = ` `; } customElements.define('pf-fulltext', FullText); } ================================================ FILE: src/PopForums.Mvc/Client/Components/HomeUpdater.ts ================================================ namespace PopForums { export class HomeUpdater extends HTMLElement { constructor() { super(); } async connectedCallback() { let service = await MessagingService.GetService(); let connection = service.connection; let self = this; connection.on("notifyForumUpdate", function (data: any) { self.updateForumStats(data); }); await connection.invoke("listenToAllForums"); } updateForumStats(data: any) { let row = document.querySelector("[data-forumid='" + data.forumID + "']")!; row.querySelector(".topicCount")!.innerHTML = data.topicCount; row.querySelector(".postCount")!.innerHTML = data.postCount; row.querySelector(".lastPostName")!.innerHTML = data.lastPostName; row.querySelector("pf-formattedtime")!.setAttribute("utctime", data.utc); row.querySelector(".newIndicator .icon")!.classList.remove("text-muted", "icon-file-earmark-text"); row.querySelector(".newIndicator .icon")!.classList.add("text-warning", "icon-file-earmark-text-fill"); }; } customElements.define('pf-homeupdater', HomeUpdater); } ================================================ FILE: src/PopForums.Mvc/Client/Components/LoginForm.ts ================================================ namespace PopForums { export class LoginForm extends HTMLElement { constructor() { super(); } get templateid() { return this.getAttribute("templateid"); } get isExternalLogin() { return this.getAttribute("isexternallogin"); } private button!: HTMLInputElement; private email!: HTMLInputElement; private password!: HTMLInputElement; connectedCallback() { let template = document.getElementById(this.templateid!) as HTMLTemplateElement; if (!template) { console.error(`Can't find templateID ${this.templateid} to make login form.`); return; } this.append(template.content.cloneNode(true)); this.email = this.querySelector("#EmailLogin")!; this.password = this.querySelector("#PasswordLogin")!; this.button = this.querySelector("#LoginButton")!; this.button.addEventListener("click", () => { this.executeLogin(); }); this.querySelectorAll("#EmailLogin,#PasswordLogin").forEach(x => x.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).code === "Enter") this.executeLogin(); }) ); } executeLogin() { let path = "/Identity/Login"; if (this.isExternalLogin!.toLowerCase() === "true") path = "/Identity/LoginAndAssociate"; let payload = JSON.stringify({ email: this.email.value, password: this.password.value }); fetch(PopForums.AreaPath + path, { method: "POST", headers: { 'Content-Type': 'application/json' }, body: payload }) .then(function(response) { return response.json(); }) .then(function (result) { switch (result.result) { case true: let destination = (document.querySelector("#Referrer") as HTMLInputElement).value; location.href = destination; break; default: let loginResult = document.querySelector("#LoginResult")!; loginResult.innerHTML = result.message; loginResult.classList.remove("d-none"); } }) .catch(function (error) { let loginResult = document.querySelector("#LoginResult")!; loginResult.innerHTML = "There was an unknown error while attempting login"; loginResult.classList.remove("d-none"); }); } } customElements.define('pf-loginform', LoginForm); } ================================================ FILE: src/PopForums.Mvc/Client/Components/MorePostsBeforeReplyButton.ts ================================================ namespace PopForums { export class MorePostsBeforeReplyButton extends ElementBase { constructor() { super(); } get buttonclass(): string { return this.getAttribute("buttonclass")!; } get buttontext(): string { return this.getAttribute("buttontext")!; } connectedCallback() { this.innerHTML = MorePostsBeforeReplyButton.template; let button = this.querySelector("input") as HTMLInputElement; button.value = this.buttontext; if (this.buttonclass?.length > 0) this.buttonclass.split(" ").forEach((c) => button.classList.add(c)); button.addEventListener("click", (e: MouseEvent) => { PopForums.currentTopicState.loadMorePosts(); }); super.connectedCallback(); } getDependentReference(): [TopicState, string] { return [PopForums.currentTopicState, "isNewerPostsAvailable"]; } updateUI(data: boolean): void { let button = this.querySelector("input")!; if (!data) button.style.visibility = "hidden"; else button.style.visibility = "visible"; } static template: string = ``; } customElements.define('pf-morepostsbeforereplybutton', MorePostsBeforeReplyButton); } ================================================ FILE: src/PopForums.Mvc/Client/Components/MorePostsButton.ts ================================================ namespace PopForums { export class MorePostsButton extends ElementBase { constructor() { super(); } get buttonclass(): string { return this.getAttribute("buttonclass")!; } get buttontext(): string { return this.getAttribute("buttontext")!; } connectedCallback() { this.innerHTML = MorePostsButton.template; let button = this.querySelector("input") as HTMLInputElement; button.value = this.buttontext; if (this.buttonclass?.length > 0) this.buttonclass.split(" ").forEach((c) => button.classList.add(c)); button.addEventListener("click", (e: MouseEvent) => { PopForums.currentTopicState.loadMorePosts(); }); super.connectedCallback(); } getDependentReference(): [TopicState, string] { return [PopForums.currentTopicState, "highPage"]; } updateUI(data: number): void { let button = this.querySelector("input")!; if (PopForums.currentTopicState.pageCount === 1 || data === PopForums.currentTopicState.pageCount) button.style.visibility = "hidden"; else button.style.visibility = "visible"; } static template: string = ``; } customElements.define('pf-morepostsbutton', MorePostsButton); } ================================================ FILE: src/PopForums.Mvc/Client/Components/NotificationItem.ts ================================================ namespace PopForums { export class NotificationItem extends HTMLElement { constructor(notification: Notification) { super(); this.notification = notification; } notification: Notification; private escapeHtml(s: string): string { return (s ?? "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } connectedCallback() { let markup: string = ""; let link: string = ""; switch (this.notification.notificationType) { case 3: // Award markup = `${PopForums.localizations.award}: ${this.escapeHtml(this.notification.data.title)}`; link = "/Forums/Account/ViewProfile/" + this.notification.userID + "#Awards"; break; case 2: // QuestionAnswered markup = PopForums.localizations.questionAnsweredNotification .replace("{0}", this.escapeHtml(this.notification.data.askerName)) .replace("{1}", this.escapeHtml(this.notification.data.title)); link = "/Forums/PostLink/" + this.notification.data.postID; break; case 0: // NewReply markup = PopForums.localizations.newReplyNotification .replace("{0}", this.escapeHtml(this.notification.data.postName)) .replace("{1}", this.escapeHtml(this.notification.data.title)); link = "/Forums/Forum/GoToNewestPost/" + this.notification.data.topicID; break; case 1: // VoteUp markup = PopForums.localizations.voteUpNotification .replace("{0}", this.escapeHtml(this.notification.data.voterName)) .replace("{1}", this.escapeHtml(this.notification.data.title)); link = "/Forums/PostLink/" + this.notification.data.postID; break; default: console.log(`Unknown notification type: ${this.notification.notificationType}`); } let newness = " border border-2"; if (!this.notification.isRead) newness = " text-bg-light border-primary border border-2"; let template = `

    ${markup}

    `; this.innerHTML = template; let timeStamp = new FormattedTime(); timeStamp.setAttribute("utctime", this.notification.timeStamp.toString()); let footer = this.querySelector(".card-footer")!; footer.append(timeStamp); this.querySelector("a")!.addEventListener("click", (e) => { PopForums.userState.MarkRead(this.notification.contextID, this.notification.notificationType); }); } MarkRead() { let box = this.querySelector(".card"); if (box) { box.classList.remove("text-bg-light", "border-primary"); } this.notification.isRead = true; } } customElements.define('pf-notificationitem', NotificationItem); } ================================================ FILE: src/PopForums.Mvc/Client/Components/NotificationList.ts ================================================ namespace PopForums { export class NotificationList extends ElementBase { constructor() { super(); } connectedCallback() { super.connectedCallback(); } getDependentReference(): [UserState, string] { return [PopForums.userState, "notifications"]; } updateUI(data: Array): void { if (!data || data.length === 0) { this.replaceChildren(); return; } data.forEach(item => { let n = new NotificationItem(item); this.append(n); }); } } customElements.define('pf-notificationlist', NotificationList); } ================================================ FILE: src/PopForums.Mvc/Client/Components/NotificationMarkAllButton.ts ================================================ namespace PopForums { export class NotificationMarkAllButton extends HTMLElement { constructor() { super(); } get buttonclass(): string { return this.getAttribute("buttonclass")!; } get buttontext(): string { return this.getAttribute("buttontext")!; } connectedCallback() { this.innerHTML = ``; this.querySelector("input")!.addEventListener("click", () => { PopForums.userState.MarkAllRead(); }); } } customElements.define('pf-notificationmarkallbutton', NotificationMarkAllButton); } ================================================ FILE: src/PopForums.Mvc/Client/Components/NotificationToggle.ts ================================================ namespace PopForums { export class NotificationToggle extends ElementBase { constructor() { super(); this.userState = PopForums.userState; this.isInit = false; } get panelid(): string { return this.getAttribute("panelid")!; } get notificationlistid(): string { return this.getAttribute("notificationlistid")!; } private userState: UserState; private isReady!: boolean; private panel!: HTMLElement; private offCanvas!: bootstrap.Offcanvas; private isInit: boolean; connectedCallback() { const delegate = this.ready.bind(this); this.isReady = LocalizationService.subscribe(delegate); if (this.isReady) this.ready(); super.connectedCallback(); } ready() { this.title = PopForums.localizations.notifications; this.panel = document.getElementById(this.panelid)!; this.offCanvas = new bootstrap.Offcanvas(this.panel); this.addEventListener("click", this.show); let list = document.getElementById(this.notificationlistid)!; this.userState.list = list; } private async show() { this.offCanvas.show(); this.panel.addEventListener("hide.bs.offcanvas", event => { this.userState.list.removeEventListener("scroll", this.userState.ScrollLoad); }); await this.userState.LoadNotifications(); this.userState.list.addEventListener("scroll", this.userState.ScrollLoad); } getDependentReference(): [UserState, string] { return [PopForums.userState, "notificationCount"]; } updateUI(data: number): void { if (data === 0) this.innerHTML = ``; else { this.innerHTML = `${data}`; if (this.isInit) { this.innerHTML = `${data}`; } } this.isInit = true; } } customElements.define('pf-notificationtoggle', NotificationToggle); } ================================================ FILE: src/PopForums.Mvc/Client/Components/PMCount.ts ================================================ namespace PopForums { export class PMCount extends ElementBase { constructor() { super(); this.isInit = false; } private isInit: boolean; getDependentReference(): [UserState, string] { return [PopForums.userState, "newPmCount"]; } updateUI(data: number): void { if (data === 0) this.innerHTML = ""; else { this.innerHTML = `${data}`; if (this.isInit) this.innerHTML = `${data}`; } this.isInit = true; } } customElements.define('pf-pmcount', PMCount); } ================================================ FILE: src/PopForums.Mvc/Client/Components/PMForm.ts ================================================ namespace PopForums { export class PMForm extends HTMLElement { constructor() { super(); } private isReady!: boolean; connectedCallback() { const delegate = this.ready.bind(this); this.isReady = LocalizationService.subscribe(delegate); if (this.isReady) this.ready(); } ready() { this.innerHTML = PMForm.template; let button = this.querySelector("button")!; button.innerHTML = PopForums.localizations.send; let textBox = this.querySelector("textarea") as HTMLTextAreaElement; textBox.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); this.send(textBox); } }); button.addEventListener("click", () => { this.send(textBox); }); } private send(textBox: HTMLTextAreaElement) { PopForums.currentPmState.send(textBox.value); textBox.value = ""; } static template: string = `
    `; } customElements.define('pf-pmform', PMForm); } ================================================ FILE: src/PopForums.Mvc/Client/Components/PostMiniProfile.ts ================================================ namespace PopForums { export class PostMiniProfile extends HTMLElement { constructor() { super(); } get username(): string { return this.getAttribute("username")!; } get usernameclass(): string { return this.getAttribute("usernameclass")!; } get userid(): string { return this.getAttribute("userid")!; } get miniProfileBoxClass(): string { return this.getAttribute("miniprofileboxclass")!; } private isOpen!: boolean; private box!: HTMLElement; private boxHeight!: string; private isLoaded!: boolean; connectedCallback() { this.isLoaded = false; this.innerHTML = PostMiniProfile.template; let nameHeader = this.querySelector("h3") as HTMLElement; this.usernameclass.split(" ").forEach((c) => nameHeader.classList.add(c)); nameHeader.innerHTML = this.username; nameHeader.addEventListener("click", () => { this.toggle(); }); this.box = this.querySelector("div")!; this.miniProfileBoxClass.split(" ").forEach((c) => this.box.classList.add(c)); } private toggle() { if (!this.isLoaded) { fetch(PopForums.AreaPath + "/Account/MiniProfile/" + this.userid) .then(response => response.text() .then(text => { let sub = this.box.querySelector("div")!; sub.innerHTML = text; const height = sub.getBoundingClientRect().height; this.boxHeight = `${height}px`; this.box.style.height = this.boxHeight; this.isOpen = true; this.isLoaded = true; })); } else if (!this.isOpen) { this.box.style.height = this.boxHeight; this.isOpen = true; } else { this.box.style.height = "0"; this.isOpen = false; } } static template: string = `

    `; } customElements.define('pf-postminiprofile', PostMiniProfile); } ================================================ FILE: src/PopForums.Mvc/Client/Components/PostModerationLogButton.ts ================================================ namespace PopForums { export class PostModerationLogButton extends HTMLElement { constructor() { super(); } get buttonclass(): string { return this.getAttribute("buttonclass")!; } get buttontext(): string { return this.getAttribute("buttontext")!; } get postid(): string { return this.getAttribute("postid")!; } get parentSelectorToAppendTo(): string { return this.getAttribute("parentselectortoappendto")!; } connectedCallback() { this.innerHTML = PostModerationLogButton.template; let button = this.querySelector("input")!; button.value = this.buttontext; let classes = this.buttonclass; if (classes?.length > 0) classes.split(" ").forEach((c) => button.classList.add(c)); let self = this; let container: HTMLDivElement; button.addEventListener("click", () => { if (!container) { let parentContainer = self.closest(this.parentSelectorToAppendTo); if (!parentContainer) { console.error(`Can't find a parent selector "${this.parentSelectorToAppendTo}" to append post moderation log to.`); return; } container = document.createElement("div"); container.classList.add("my-3"); parentContainer.appendChild(container); } if (container.style.display !== "block") fetch(PopForums.AreaPath + "/Moderator/PostModerationLog/" + this.postid) .then(response => response.text() .then(text => { container.innerHTML = text; container.style.display = "block"; })); else container.style.display = "none"; }); } static template: string = ``; } customElements.define("pf-postmoderationlogbutton", PostModerationLogButton); } ================================================ FILE: src/PopForums.Mvc/Client/Components/PreviewButton.ts ================================================ namespace PopForums { export class PreviewButton extends HTMLElement { constructor() { super(); } get labelText(): string { return this.getAttribute("labeltext")!; } get textSourceSelector(): string { return this.getAttribute("textsourceselector")!; } get isPlainTextSelector(): string { return this.getAttribute("isplaintextselector")!; } connectedCallback() { this.innerHTML = PreviewButton.template; let button = this.querySelector("input")!; button.value = this.labelText; let headText = this.querySelector("h4") as HTMLHeadElement; headText.innerText = this.labelText; var modal = this.querySelector(".modal")!; modal.addEventListener("shown.bs.modal", () => { this.openModal(); }); } openModal() { tinymce.triggerSave(); let fullText = document.querySelector(this.textSourceSelector) as any; let model = { FullText: fullText.value, IsPlainText: (document.querySelector(this.isPlainTextSelector) as HTMLInputElement).value.toLowerCase() === "true" }; fetch(PopForums.AreaPath + "/Forum/PreviewText", { method: "POST", body: JSON.stringify(model), headers: { "Content-Type": "application/json" } }) .then(response => response.text() .then(text => { let r = this.querySelector(".parsedFullText") as HTMLDivElement; r.innerHTML = text; })); } static template: string = ` `; } customElements.define('pf-previewbutton', PreviewButton); } ================================================ FILE: src/PopForums.Mvc/Client/Components/PreviousPostsButton.ts ================================================ namespace PopForums { export class PreviousPostsButton extends ElementBase { constructor() { super(); } get buttonclass(): string { return this.getAttribute("buttonclass")!; } get buttontext(): string { return this.getAttribute("buttontext")!; } connectedCallback() { this.innerHTML = PreviousPostsButton.template; let button = this.querySelector("input") as HTMLInputElement; button.value = this.buttontext; if (this.buttonclass?.length > 0) this.buttonclass.split(" ").forEach((c) => button.classList.add(c)); button.addEventListener("click", (e: MouseEvent) => { PopForums.currentTopicState.loadPreviousPosts(); }); super.connectedCallback(); } getDependentReference(): [TopicState, string] { return [PopForums.currentTopicState, "lowPage"]; } updateUI(data: number): void { let button = this.querySelector("input")!; if (PopForums.currentTopicState.pageCount === 1 || data === 1) button.style.visibility = "hidden"; else button.style.visibility = "visible"; } static template: string = ``; } customElements.define('pf-previouspostsbutton', PreviousPostsButton); } ================================================ FILE: src/PopForums.Mvc/Client/Components/QuoteButton.ts ================================================ namespace PopForums { export class QuoteButton extends HTMLElement { constructor() { super(); } get name(): string { return this.getAttribute("name")!; } get containerid(): string { return this.getAttribute("containerid")!; } get buttonclass(): string { return this.getAttribute("buttonclass")!; } get buttontext(): string { return this.getAttribute("buttontext")!; } get tip(): string { return this.getAttribute("tip")!; } get postID(): string { return this.getAttribute("postid")!; } private _tip: any; connectedCallback() { let targetText = document.getElementById(this.containerid)!; this.innerHTML = QuoteButton.template; let button = this.querySelector("button")!; button.title = this.tip; ["mousedown","touchstart"].forEach((e:string) => targetText.addEventListener(e, () => { if (this._tip) this._tip.hide() })); button.value = this.buttontext; let classes = this.buttonclass; if (classes?.length > 0) classes.split(" ").forEach((c) => button.classList.add(c)); this.onclick = (e: MouseEvent) => { // get this from topic state's callback/ready method, because iOS loses selection when you touch quote button let fragment = PopForums.currentTopicState.documentFragment; let ancestor = PopForums.currentTopicState.selectionAncestor as Element; if (!fragment) { let selection = document.getSelection(); if (!selection || selection.rangeCount === 0 || selection.getRangeAt(0).toString().length === 0) { // prompt to select this._tip = new bootstrap.Tooltip(button, {trigger: "manual"}); this._tip.show(); return; } let range = selection.getRangeAt(0); ancestor = range.commonAncestorContainer as Element; fragment = range.cloneContents(); } let div = document.createElement("div"); div.appendChild(fragment); // is selection in the container? while (ancestor.id !== this.containerid && ancestor.parentElement !== null) { ancestor = ancestor.parentElement; } let isInText = ancestor.id === this.containerid; // if not, is it partially in the container? if (!isInText) { let container = div.querySelector("#" + this.containerid); if (container !== null && container !== undefined) { // it's partially in the container, so just get that part div.innerHTML = container.innerHTML; isInText = true; } } if (isInText) { // activate or add to quote let result: string; if (userState.isPlainText) result = `[quote][i]${this.name}:[/i]\r\n ${div.innerText}[/quote]`; else result = `

    ${this.name}:

    ${div.innerHTML}

    `; PopForums.currentTopicState.nextQuote = result; if (!PopForums.currentTopicState.isReplyLoaded) PopForums.currentTopicState.loadReply(PopForums.currentTopicState.topicID, Number(this.postID), true); } let temp = document.getSelection(); if (temp) temp.removeAllRanges(); }; } static template: string = ``; } customElements.define('pf-replybutton', ReplyButton); } ================================================ FILE: src/PopForums.Mvc/Client/Components/ReplyForm.ts ================================================ namespace PopForums { export class ReplyForm extends HTMLElement { constructor() { super(); } get templateID() { return this.getAttribute("templateid"); } private button!: HTMLInputElement; connectedCallback() { let template = document.getElementById(this.templateID!) as HTMLTemplateElement; if (!template) { console.error(`Can't find templateID ${this.templateID} to make reply form.`); return; } this.append(template.content.cloneNode(true)); this.button = this.querySelector("#SubmitReply")!; this.button.addEventListener("click", () => { this.submitReply(); }); } submitReply() { this.button.setAttribute("disabled", "disabled"); let closeCheck = document.querySelector("#CloseOnReply") as HTMLInputElement; let closeOnReply = false; if (closeCheck && closeCheck.checked) closeOnReply = true; let postImageIDs = PopForums.userState.postImageIds; let model = { Title: (this.querySelector("#NewReply #Title") as HTMLInputElement).value, FullText: (this.querySelector("#NewReply #FullText") as HTMLInputElement).value, IncludeSignature: (this.querySelector("#NewReply #IncludeSignature") as HTMLInputElement).checked, ItemID: (this.querySelector("#NewReply #ItemID") as HTMLInputElement).value, CloseOnReply: closeOnReply, IsPlainText: (this.querySelector("#NewReply #IsPlainText") as HTMLInputElement).value.toLowerCase() === "true", ParentPostID: (this.querySelector("#NewReply #ParentPostID") as HTMLInputElement).value, PostImageIDs: postImageIDs }; fetch(PopForums.AreaPath + "/Forum/PostReply", { method: "POST", body: JSON.stringify(model), headers: { "Content-Type": "application/json" }, }) .then(response => response.json()) .then(result => { switch (result.result) { case true: window.location = result.redirect; break; default: var r = this.querySelector("#PostResponseMessage") as HTMLElement; r.innerHTML = result.message; this.button.removeAttribute("disabled"); r.style.display = "block"; } }) .catch(error => { var r = this.querySelector("#PostResponseMessage") as HTMLElement; r.innerHTML = "There was an unknown error while trying to post"; this.button.removeAttribute("disabled"); r.style.display = "block"; }); }; } customElements.define('pf-replyform', ReplyForm); } ================================================ FILE: src/PopForums.Mvc/Client/Components/SearchNavForm.ts ================================================ namespace PopForums { export class SearchNavForm extends HTMLElement { constructor() { super(); } get templateid() { return this.getAttribute("templateid"); } get textboxid() { return this.getAttribute("textboxid"); } get dropdownid() { return this.getAttribute("dropdownid"); } private searchBox!: HTMLInputElement; private dropdown!: HTMLElement; connectedCallback() { let template = document.getElementById(this.templateid!) as HTMLTemplateElement; if (!template) { console.error(`Can't find templateID ${this.templateid} to make search form.`); return; } this.append(template.content.cloneNode(true)); this.searchBox = this.querySelector("#" + this.textboxid)!; this.dropdown = this.querySelector("#" + this.dropdownid)!; this.dropdown.addEventListener("shown.bs.dropdown", () => { this.searchBox.focus(); }); } } customElements.define('pf-searchnavform', SearchNavForm); } ================================================ FILE: src/PopForums.Mvc/Client/Components/SubscribeButton.ts ================================================ namespace PopForums { export class SubscribeButton extends ElementBase { constructor() { super(); } get buttonclass(): string { return this.getAttribute("buttonclass")!; } get subscribetext(): string { return this.getAttribute("subscribetext")!; } get unsubscribetext(): string { return this.getAttribute("unsubscribetext")!; } connectedCallback() { this.innerHTML = SubscribeButton.template; let button: HTMLButtonElement = this.querySelector("button")!; this.buttonclass.split(" ").forEach((c) => button.classList.add(c)); button.addEventListener("click", () => { fetch(PopForums.AreaPath + "/Subscription/ToggleSubscription/" + PopForums.currentTopicState.topicID, { method: "POST" }) .then(response => response.json()) .then(result => { switch (result.data.isSubscribed) { case true: PopForums.currentTopicState.isSubscribed = true; break; case false: PopForums.currentTopicState.isSubscribed = false; break; default: // TODO: something else } }) .catch(() => { // TODO: handle error }); }); super.connectedCallback(); } getDependentReference(): [TopicState, string] { return [PopForums.currentTopicState, "isSubscribed"]; } updateUI(data: boolean): void { let button = this.querySelector("button")!; if (data) { button.title = this.unsubscribetext; button.classList.remove("icon-bell-slash", "text-muted"); button.classList.add("icon-bell-fill", "text-warning"); } else { button.title = this.subscribetext; button.classList.remove("icon-bell-fill", "text-warning"); button.classList.add("icon-bell-slash", "text-muted"); } } static template: string = ``; } customElements.define('pf-subscribebutton', SubscribeButton); } ================================================ FILE: src/PopForums.Mvc/Client/Components/TopicButton.ts ================================================ namespace PopForums { export class TopicButton extends ElementBase { constructor() { super(); } get buttonclass(): string { return this.getAttribute("buttonclass")!; } get buttontext(): string { return this.getAttribute("buttontext")!; } get forumid(): string { return this.getAttribute("forumid")!; } connectedCallback() { this.innerHTML = TopicButton.template; let button = this.querySelector("input") as HTMLInputElement; button.value = this.buttontext; if (this.buttonclass?.length > 0) this.buttonclass.split(" ").forEach((c) => button.classList.add(c)); button.addEventListener("click", () => { currentForumState.loadNewTopic(); }); super.connectedCallback(); } getDependentReference(): [ForumState, string] { return [PopForums.currentForumState, "isNewTopicLoaded"]; } updateUI(data: boolean): void { if (data) this.style.display = "none"; else this.style.display = "initial"; } static template: string = ``; } customElements.define('pf-topicbutton', TopicButton); } ================================================ FILE: src/PopForums.Mvc/Client/Components/TopicForm.ts ================================================ namespace PopForums { export class TopicForm extends HTMLElement { constructor() { super(); } get templateID() { return this.getAttribute("templateid"); } private button!: HTMLInputElement; connectedCallback() { let template = document.getElementById(this.templateID!) as HTMLTemplateElement; if (!template) { console.error(`Can't find templateID ${this.templateID} to make reply form.`); return; } this.append(template.content.cloneNode(true)); this.button = this.querySelector("#SubmitNewTopic")!; this.button.addEventListener("click", () => { this.submitTopic(); }); } submitTopic() { this.button.setAttribute("disabled", "disabled"); let postImageIDs = PopForums.userState.postImageIds; let model = { Title: (this.querySelector("#NewTopic #Title") as HTMLInputElement).value, FullText: (this.querySelector("#NewTopic #FullText")as HTMLInputElement).value, IncludeSignature: (this.querySelector("#NewTopic #IncludeSignature")as HTMLInputElement).checked, ItemID: (this.querySelector("#NewTopic #ItemID")as HTMLInputElement).value, IsPlainText: (this.querySelector("#NewTopic #IsPlainText")as HTMLInputElement).value.toLowerCase() === "true", PostImageIDs: postImageIDs }; fetch(PopForums.AreaPath + "/Forum/PostTopic", { method: "POST", body: JSON.stringify(model), headers: { "Content-Type": "application/json" }, }) .then(response => response.json()) .then(result => { switch (result.result) { case true: window.location = result.redirect; break; default: var r = this.querySelector("#PostResponseMessage") as HTMLElement; r.innerHTML = result.message; this.button.removeAttribute("disabled"); r.style.display = "block"; } }) .catch(error => { var r = this.querySelector("#PostResponseMessage") as HTMLElement; r.innerHTML = "There was an unknown error while trying to post"; this.button.removeAttribute("disabled"); r.style.display = "block"; }); }; } customElements.define('pf-topicform', TopicForm); } ================================================ FILE: src/PopForums.Mvc/Client/Components/TopicModerationLogButton.ts ================================================ namespace PopForums { export class TopicModerationLogButton extends HTMLElement { constructor() { super(); } get buttonclass(): string { return this.getAttribute("buttonclass")!; } get buttontext(): string { return this.getAttribute("buttontext")!; } get topicid(): string { return this.getAttribute("topicid")!; } connectedCallback() { this.innerHTML = TopicModerationLogButton.template; let button = this.querySelector("input")!; button.value = this.buttontext; let classes = this.buttonclass; if (classes?.length > 0) classes.split(" ").forEach((c) => button.classList.add(c)); button.addEventListener("click", () => { let container = this.querySelector("div")!; if (container.style.display !== "block") fetch(PopForums.AreaPath + "/Moderator/TopicModerationLog/" + this.topicid) .then(response => response.text() .then(text => { container.innerHTML = text; container.style.display = "block"; })); else container.style.display = "none"; }); } static template: string = `
    `; } customElements.define("pf-topicmoderationlogbutton", TopicModerationLogButton); } ================================================ FILE: src/PopForums.Mvc/Client/Components/VoteCount.ts ================================================ namespace PopForums { export class VoteCount extends HTMLElement { constructor() { super(); } get votes(): string { return this.getAttribute("votes")!; } set votes(value:string) { this.setAttribute("votes", value); } get postid(): string { return this.getAttribute("postid")!; } get containerclass(): string { return this.getAttribute("containerclass")!; } get votescontainerclass(): string { return this.getAttribute("votescontainerclass")!; } get badgeclass(): string { return this.getAttribute("badgeclass")!; } get votebuttonclass(): string { return this.getAttribute("votebuttonclass")!; } get isloggedin(): string { return this.getAttribute("isloggedin")!.toLowerCase(); } get isauthor(): string { return this.getAttribute("isauthor")!.toLowerCase(); } get isvoted(): string { return this.getAttribute("isvoted")!.toLowerCase(); } private badge!: HTMLElement; private voterContainer!: HTMLElement; private popOver!: bootstrap.Popover; private popoverEventHander!: EventListenerOrEventListenerObject; connectedCallback() { this.innerHTML = VoteCount.template; let topContainer = this.querySelector("div")!; if (this.containerclass?.length > 0) this.containerclass.split(" ").forEach((c) => topContainer.classList.add(c)); this.badge = this.querySelector("div > div")!; this.badge.innerHTML = "+" + this.votes; if (this.badgeclass?.length > 0) this.badgeclass.split(" ").forEach((c) => this.badge.classList.add(c)); let statusHtml = this.buttonGenerator(); if (statusHtml != "") { let status = document.createElement("template"); status.innerHTML = this.buttonGenerator(); this.firstElementChild!.append(status.content.firstChild!); } let voteButton = this.querySelector("span"); if (voteButton) { if (this.votebuttonclass?.length > 0) this.votebuttonclass.split(" ").forEach((c) => voteButton.classList.add(c)); type resultType = { votes: number; isVoted: boolean; } voteButton.addEventListener("click", () => { voteButton.classList.remove("icon-plus-square", "icon-plus-square-fill"); voteButton.classList.add("spinner-border", "spinner-border-sm"); fetch(PopForums.AreaPath + "/Forum/ToggleVote/" + this.postid, { method: "POST"}) .then(response => response.json() .then((result: resultType) => { this.votes = result.votes.toString(); this.badge.innerHTML = "+" + this.votes; if (result.isVoted) { voteButton.classList.remove("spinner-border", "spinner-border-sm"); voteButton.classList.add("icon-plus-square-fill"); } else { voteButton.classList.remove("spinner-border", "spinner-border-sm"); voteButton.classList.add("icon-plus-square"); } this.applyPopover(); })); }) } this.setupVoterPopover(); this.applyPopover(); } private setupVoterPopover(): void { this.voterContainer = document.createElement("div"); if (this.votescontainerclass?.length > 0) this.votescontainerclass.split(" ").forEach((c) => this.voterContainer.classList.add(c)); this.voterContainer.innerHTML = `
    Loading...
    `; this.popOver = new bootstrap.Popover(this.badge, { content: this.voterContainer, html: true, trigger: "click focus" }); this.popoverEventHander = (e) => { fetch(PopForums.AreaPath + "/Forum/Voters/" + this.postid) .then(response => response.text() .then(text => { let t = document.createElement("template"); t.innerHTML = text.trim(); this.voterContainer.innerHTML = ""; this.voterContainer.appendChild(t.content.firstChild!); })); }; this.badge.addEventListener("shown.bs.popover", this.popoverEventHander); } private applyPopover(): void { if (this.votes === "0") { this.badge.style.cursor = "default"; this.popOver.disable(); } else { this.badge.style.cursor = "pointer"; this.popOver.enable(); } } private buttonGenerator(): string { if (this.isloggedin === "false" || this.isauthor === "true") return ""; if (this.isvoted === "true") return VoteCount.cancelVoteButton; return VoteCount.voteUpButton; } static template: string = `
    `; static voteUpButton = ""; static cancelVoteButton = ""; } customElements.define("pf-votecount", VoteCount); } ================================================ FILE: src/PopForums.Mvc/Client/Declarations.ts ================================================ namespace PopForums { export const AreaPath = "/Forums"; export var currentTopicState: TopicState; export var currentForumState: ForumState; export var currentPmState: PrivateMessageState; export var userState: UserState; export var localizations: Localizations; export function Ready(callback: any): void { if (document.readyState != "loading") callback(); else document.addEventListener("DOMContentLoaded", callback); } } declare namespace tinymce { function init(options:any): any; function get(id:string): any; function triggerSave(): any; let activeEditor: any; } declare class BlobInfo { id: () => string; name: () => string; filename: () => string; blob: () => Blob; base64: () => string; blobUri: () => string; uri: () => string | undefined; } declare namespace bootstrap { class Tooltip { constructor(el: Element, options:any); } class Popover { constructor(el: Element, options:any); enable(): void; disable(): void; } class Offcanvas extends HTMLElement { constructor(el: Element); show(): void; } } declare namespace signalR { enum HttpTransportType { WebSockets = 1, ServerSentEvents = 2, LongPolling = 4 } class HubConnectionBuilder { withUrl(url: string, options?: { skipNegotiation?: boolean; transport?: HttpTransportType }): any; } } ================================================ FILE: src/PopForums.Mvc/Client/ElementBase.ts ================================================ namespace PopForums { export abstract class ElementBase extends HTMLElement { connectedCallback() { if (this.state && this.propertyToWatch) return; let stateAndWatchProperty = this.getDependentReference(); this.state = stateAndWatchProperty[0]; this.propertyToWatch = stateAndWatchProperty[1]; const delegate = this.update.bind(this); this.state.subscribe(this.propertyToWatch, delegate); } private state!: B; private propertyToWatch!: string; update() { const externalValue = this.state[this.propertyToWatch as keyof B]; this.updateUI(externalValue); } // Implementation should return the StateBase and property (as a string) to monitor abstract getDependentReference(): [B, string]; // Use in the implementation to manipulate the shadow or light DOM or straight markup as needed in response to the new data. abstract updateUI(data: any): void; } } ================================================ FILE: src/PopForums.Mvc/Client/Models/Notification.ts ================================================ namespace PopForums { export class Notification { userID!: number; timeStamp!: Date; isRead!: boolean; notificationType!: number; contextID!: number; data: any; unreadCount!: number; } } ================================================ FILE: src/PopForums.Mvc/Client/Models/PrivateMessage.ts ================================================ namespace PopForums { export class PrivateMessage { pmPostID!: number; userID!: number; name!: string; postTime!: Date; fullText!: string; } } ================================================ FILE: src/PopForums.Mvc/Client/Models/PrivateMessageUser.ts ================================================ namespace PopForums { export class PrivateMessageUser { userID!: number; name!: string; } } ================================================ FILE: src/PopForums.Mvc/Client/Services/LocalizationService.ts ================================================ namespace PopForums { export class LocalizationService { static init(): void { const path = PopForums.AreaPath + "/Resources"; fetch(path) .then(response => { return response.json(); }) .then(json => { PopForums.localizations = Object.assign(new Localizations(), json); return this.signal(); }); } private static signal() { PopForums.Ready(() => { if (this.readies) { for (let i of this.readies) { i(); } } this.isSignaled = true; }); } static readies: Array; private static isSignaled: boolean = false; static subscribe(ready: Function): boolean { if (!this.readies) this.readies = new Array(); this.readies.push(ready); return this.isSignaled; } } } ================================================ FILE: src/PopForums.Mvc/Client/Services/MessagingService.ts ================================================ namespace PopForums { export class MessagingService { private static service: MessagingService; private static promise: Promise; static async GetService(): Promise { if (!this.promise) { const service = new MessagingService(); this.promise = service.start(); this.service = service; } await Promise.all([this.promise]); return this.service; } connection: any; private async start() { this.connection = new signalR.HubConnectionBuilder() .withUrl("/PopForumsHub", { skipNegotiation: true, transport: signalR.HttpTransportType.WebSockets }) .withAutomaticReconnect() .build(); await this.connection.start(); } } } ================================================ FILE: src/PopForums.Mvc/Client/Services/NotificationService.ts ================================================ namespace PopForums { export class NotificationService { constructor(userState: UserState) { this.userState = userState; } private userState: UserState; private connection: any; async initialize(): Promise { let self = this; let service = await MessagingService.GetService(); this.connection = service.connection; this.connection.on("updatePMCount", function (pmCount: number) { self.userState.newPmCount = pmCount; }); this.connection.on("notify", function (data: any) { let notification: Notification = Object.assign(new Notification(), data); let list = self.userState.list.querySelectorAll("pf-notificationitem"); list.forEach(item => { let nitem = (item as NotificationItem).notification; if (nitem.contextID === notification.contextID && nitem.notificationType === notification.notificationType) { item.remove(); } }); self.userState.notificationCount = notification.unreadCount; self.userState.notifications.unshift(notification); }); this.connection.onreconnected(async () => { let notificationCount = await this.connection.invoke("GetNotificationCount"); self.userState.notificationCount = notificationCount; let pmCount = await this.connection.invoke("GetPMCount"); self.userState.newPmCount = pmCount; }); } async LoadNotifications(): Promise { const json = await this.getNotifications(); let a = new Array(); let isEnd = true; json.forEach((item: Notification) => { let n = Object.assign(new Notification(), item); a.push(n); this.userState.lastNotificationDate = n.timeStamp; isEnd = false; }); this.userState.isNotificationEnd = isEnd; if (!isEnd) this.userState.notifications = a; } async MarkRead(contextID: number, notificationType: number) : Promise { await this.connection.invoke("MarkNotificationRead", contextID, notificationType); } async MarkAllRead() : Promise { await this.connection.send("MarkAllRead"); let list = this.userState.list.querySelectorAll("pf-notificationitem"); list.forEach(item => { (item as NotificationItem).MarkRead(); }); this.userState.notificationCount = 0; } private async getNotifications() { const response = await this.connection.invoke("GetNotifications", this.userState.lastNotificationDate); return response; } } } ================================================ FILE: src/PopForums.Mvc/Client/State/ForumState.ts ================================================ namespace PopForums { export class ForumState extends StateBase { constructor() { super(); } forumID!: number; pageSize!: number; pageIndex!: number; @WatchProperty isNewTopicLoaded!: boolean; setupForum() { PopForums.Ready(async () => { this.isNewTopicLoaded = false; await this.forumListen(); }); } loadNewTopic() { fetch(PopForums.AreaPath + "/Forum/PostTopic/" + this.forumID) .then((response) => { return response.text(); }) .then((body) => { var n = document.querySelector("#NewTopic") as HTMLElement; if (!n) throw("Can't find a #NewTopic element to load in the new topic form."); n.innerHTML = body; n.style.display = "block"; this.isNewTopicLoaded = true; }); } async forumListen() { let service = await MessagingService.GetService(); let connection = service.connection; let self = this; connection.on("notifyUpdatedTopic", function (data: any) { // TODO: refactor to strong type let removal = document.querySelector('#TopicList tr[data-topicID="' + data.topicID + '"]'); if (removal) { removal.remove(); } else { let rows = document.querySelectorAll("#TopicList tr:not(#TopicTemplate)"); if (rows.length == self.pageSize) rows[rows.length - 1].remove(); } let row = self.populateTopicRow(data); row.classList.remove("hidden"); document.querySelector("#TopicList tbody")!.prepend(row); }); await connection.invoke("listenToForum", self.forumID); } async recentListen() { let service = await MessagingService.GetService(); let connection = service.connection; let self = this; connection.on("notifyRecentUpdate", function (data: any) { var removal = document.querySelector('#TopicList tr[data-topicID="' + data.topicID + '"]'); if (removal) { removal.remove(); } else { var rows = document.querySelectorAll("#TopicList tr:not(#TopicTemplate)"); if (rows.length == self.pageSize) rows[rows.length - 1].remove(); } var row = self.populateTopicRow(data); row.classList.remove("hidden"); document.querySelector("#TopicList tbody")!.prepend(row); }); connection.invoke("listenRecent"); } populateTopicRow = function (data: any) { let row = document.querySelector("#TopicTemplate")!.cloneNode(true) as HTMLElement; row.setAttribute("data-topicid", data.topicID); row.removeAttribute("id"); row.querySelector(".startedByName")!.textContent = data.startedByName; row.querySelector(".indicatorLink")!.setAttribute("href", data.link); row.querySelector(".titleLink")!.textContent = data.title; row.querySelector(".titleLink")!.setAttribute("href", data.link); var forumTitle = row.querySelector(".forumTitle"); if (forumTitle) forumTitle.textContent = data.forumTitle; row.querySelector(".viewCount")!.innerHTML = data.viewCount; row.querySelector(".replyCount")!.innerHTML = data.replyCount; row.querySelector(".lastPostName")!.textContent = data.lastPostName; row.querySelector("pf-formattedtime")!.setAttribute("utctime", data.utc); return row; }; } } ================================================ FILE: src/PopForums.Mvc/Client/State/Localizations.ts ================================================ namespace PopForums { export class Localizations { todayTime!: string; yesterdayTime!: string; minutesAgo!: string; oneMinuteAgo!: string; lessThanMinute!: string; notifications!: string; newReplyNotification!: string; award!: string; voteUpNotification!: string; questionAnsweredNotification!: string; send!: string; uploadImage!: string; } } ================================================ FILE: src/PopForums.Mvc/Client/State/PrivateMessageState.ts ================================================ namespace PopForums{ export class PrivateMessageState extends StateBase { constructor() { super(); this.isStart = false; } pmID!: number; users!: PrivateMessageUser[]; messages!: PrivateMessage[]; newestPostID!: number; private postStream!: HTMLElement; private connection: any; private isStart: boolean; setupPm() { PopForums.Ready(async () => { this.postStream = document.getElementById("PostStream")!; this.messages.forEach(x => { let messageRow = this.populateMessage(x); this.postStream.append(messageRow); }); let service = await MessagingService.GetService(); this.connection = service.connection; let self = this; this.connection.on("addMessage", function(message: PrivateMessage) { let messageRow = self.populateMessage(message); let parent = self.postStream.parentElement!; let isBottom = parent.scrollHeight - parent.scrollTop - parent.clientHeight < 200; self.postStream.append(messageRow); if (isBottom) parent.scrollTop = parent.scrollHeight; self.ackRead(); }); this.connection.onreconnected(async () => { let latestPostTime = this.messages[this.messages.length - 1].pmPostID; const posts = await this.connection.invoke("GetMostRecentPmPosts", this.pmID, latestPostTime) as PrivateMessage[]; posts.reverse().forEach((item: PrivateMessage) => { let m = this.populateMessage(item); this.postStream.append(m); }); }); await this.connection.invoke("listenToPm", this.pmID); if (this.newestPostID) { this.scrollToElement("p" + this.newestPostID) } else { this.postStream.parentElement!.scrollTop = this.postStream.parentElement!.scrollHeight; } await this.LoadCheck(); this.postStream.parentElement!.addEventListener("scroll", this.ScrollLoad); }); } ScrollLoad = async () => { await this.LoadCheck(); } async LoadCheck() { let box = this.postStream.parentElement!; if (!this.isStart && box.scrollTop < 250) { const posts = await this.GetPosts(); let isStart = true; posts.reverse().forEach((item: PrivateMessage) => { this.messages.unshift(item); let m = this.populateMessage(item); this.postStream.prepend(m); isStart = false; }); this.isStart = isStart; } } private async GetPosts() { let earliestPostTime = this.messages[0].postTime; const response = await this.connection.invoke("GetPmPosts", this.pmID, earliestPostTime) as PrivateMessage[]; return response; } send(fullText: string) { if (!fullText || fullText.trim().length === 0) return; this.connection.invoke("sendPm", this.pmID, fullText); } ackRead() { this.connection.invoke("ackReadPm", this.pmID); } populateMessage(data: PrivateMessage) { let template = document.createElement("template"); template.innerHTML = PrivateMessageState.template; let messageRow = template.content.cloneNode(true) as HTMLElement; let body = messageRow.querySelector("div > div")!; body.innerHTML = data.fullText; if (data.userID === PopForums.userState.userID) { body.classList.add("alert-secondary"); messageRow.querySelector("div")!.classList.add("ms-auto"); } else body.classList.add("alert-primary"); let timeStamp = messageRow.querySelector("pf-formattedtime")!; timeStamp.setAttribute("utctime", data.postTime.toString()); let name = messageRow.querySelector(".messageName")!; name.innerHTML = data.name; body.parentElement!.id = "p" + data.pmPostID; return messageRow; }; scrollToElement = (id: string) => { let e = document.getElementById(id) as HTMLElement; e.scrollIntoView(); }; static template: string = `
    `; } } ================================================ FILE: src/PopForums.Mvc/Client/State/TopicState.ts ================================================ namespace PopForums { export class TopicState extends StateBase { constructor() { super(); } topicID!: number; isImageEnabled!: boolean; @WatchProperty isReplyLoaded!: boolean; @WatchProperty answerPostID!: number; @WatchProperty lowPage!:number; @WatchProperty highPage!: number; lastVisiblePostID!: number; @WatchProperty isNewerPostsAvailable!: boolean; pageIndex!: number; pageCount!: number; loadingPosts: boolean = false; isScrollAdjusted: boolean = false; @WatchProperty commentReplyID!: number; @WatchProperty nextQuote!: string; @WatchProperty isSubscribed!: boolean; @WatchProperty isFavorite!: boolean; documentFragment!: DocumentFragment; selectionAncestor!: Node; setupTopic() { PopForums.Ready(async () => { this.isReplyLoaded = false; this.isNewerPostsAvailable = false; this.lowPage = this.pageIndex; this.highPage = this.pageIndex; // signalR connections let service = await MessagingService.GetService(); let connection = service.connection; let self = this; // for all posts loaded but reply not open connection.on("fetchNewPost", function (postID: number) { if (!self.isReplyLoaded && self.highPage === self.pageCount) { fetch(PopForums.AreaPath + "/Forum/Post/" + postID) .then(response => response.text() .then(text => { var t = document.createElement("template"); t.innerHTML = text.trim(); document.querySelector("#PostStream")!.appendChild(t.content.firstChild!); })); self.lastVisiblePostID = postID; } }); // for reply already open connection.on("notifyNewPosts", function (theLastPostID: number) { self.setMorePostsAvailable(theLastPostID); }); connection.invoke("listenToTopic", this.topicID); this.connection = connection; document.querySelectorAll(".postItem img:not(.avatar)").forEach(x => x.classList.add("postImage")); this.scrollToPostFromHash(); window.addEventListener("scroll", this.scrollLoad); // compensate for iOS losing selection when you touch the quote button document.querySelectorAll(".postBody").forEach( x => x.addEventListener("touchend", (e) => { let selection = document.getSelection(); if (!selection || selection.rangeCount === 0 || selection.getRangeAt(0).toString().length === 0) { return; } let range = selection.getRangeAt(0); this.selectionAncestor = range.commonAncestorContainer; this.documentFragment = range.cloneContents(); })); }); } loadReply(topicID:number, replyID:number, setupMorePosts:boolean):void { if (this.isReplyLoaded) { this.scrollToElement("NewReply"); return; } window.removeEventListener("scroll", this.scrollLoad); var path = PopForums.AreaPath + "/Forum/PostReply/" + topicID; if (replyID != null) { path += "?replyID=" + replyID; } fetch(path) .then(response => response.text() .then(text => { let n = document.querySelector("#NewReply") as HTMLElement; n!.innerHTML = text; n!.style.display = "block"; this.scrollToElement("NewReply"); this.isReplyLoaded = true; if (setupMorePosts) { let self = this; this.connection.invoke("getLastPostID", this.topicID) .then(function (result: number) { self.setMorePostsAvailable(result); }); } this.isReplyLoaded = true; this.commentReplyID = 0; })); } private connection: any; // this is intended to be called when the reply box is open private setMorePostsAvailable = (newestPostIDonServer: number) => { this.isNewerPostsAvailable = newestPostIDonServer !== this.lastVisiblePostID; } loadComment(topicID: number, replyID: number): void { var n = document.querySelector("[data-postid*='" + replyID + "'] .commentHolder"); const boxid = "commentbox"; n!.id = boxid; var path = PopForums.AreaPath + "/Forum/PostReply/" + topicID + "?replyID=" + replyID; this.commentReplyID = replyID; this.isReplyLoaded = true; fetch(path) .then(response => response.text() .then(text => { n!.innerHTML = text; this.scrollToElement(boxid); })); }; loadMorePosts = () => { let topicPagePath: string; if (this.highPage === this.pageCount) { topicPagePath = PopForums.AreaPath + "/Forum/TopicPartial/" + this.topicID + "?lastPost=" + this.lastVisiblePostID + "&lowPage=" + this.lowPage; } else { this.highPage++; topicPagePath = PopForums.AreaPath + "/Forum/TopicPage/" + this.topicID + "?pageNumber=" + this.highPage + "&low=" + this.lowPage + "&high=" + this.highPage; } fetch(topicPagePath) .then(response => response.text() .then(text => { let t = document.createElement("template"); t.innerHTML = text.trim(); let stuff = t.content.firstChild as HTMLElement; let links = stuff.querySelector(".pagerLinks"); if (links) stuff.removeChild(links); let lastPostID = stuff.querySelector(".lastPostID") as HTMLInputElement; stuff.removeChild(lastPostID); let newPageCount = stuff.querySelector(".pageCount") as HTMLInputElement; stuff.removeChild(newPageCount); this.lastVisiblePostID = Number(lastPostID.value); this.pageCount = Number(newPageCount.value); let postStream = document.querySelector("#PostStream")!; postStream.append(stuff); document.querySelectorAll(".pagerLinks").forEach(x => x.replaceWith(links!.cloneNode(true))); document.querySelectorAll(".postItem img:not(.avatar)").forEach(x => x.classList.add("postImage")); if (this.highPage == this.pageCount && this.lowPage == 1) { document.querySelectorAll(".pagerLinks").forEach(x => x.remove()); } this.loadingPosts = false; if (!this.isScrollAdjusted) { this.scrollToPostFromHash(); } if (this.isReplyLoaded) { let self = this; this.connection.invoke("getLastPostID", this.topicID) .then(function (result: number) { self.setMorePostsAvailable(result); }); } })); }; loadPreviousPosts = () => { this.lowPage--; let topicPagePath = PopForums.AreaPath + "/Forum/TopicPage/" + this.topicID + "?pageNumber=" + this.lowPage + "&low=" + this.lowPage + "&high=" + this.highPage; fetch(topicPagePath) .then(response => response.text() .then(text => { let t = document.createElement("template"); t.innerHTML = text.trim(); var stuff = t.content.firstChild as HTMLElement; var links = stuff.querySelector(".pagerLinks") as Element; stuff.removeChild(links); var postStream = document.querySelector("#PostStream")!; postStream.prepend(stuff); document.querySelectorAll(".pagerLinks").forEach(x => x.replaceWith(links.cloneNode(true))); document.querySelectorAll(".postItem img:not(.avatar)").forEach(x => x.classList.add("postImage")); if (this.highPage == this.pageCount && this.lowPage == 1) { document.querySelectorAll(".pagerLinks").forEach(x => x.remove()); } })); } scrollLoad = () => { let streamEnd = (document.querySelector("#StreamBottom") as HTMLElement); if (!streamEnd) return; // this is a QA topic, no continuous post stream let top = streamEnd.offsetTop; let viewEnd = window.scrollY + window.outerHeight; let distance = top - viewEnd; if (!this.loadingPosts && distance < 250 && this.highPage < this.pageCount) { this.loadingPosts = true; this.loadMorePosts(); } }; scrollToElement = (id: string) => { let e = document.getElementById(id) as HTMLElement; let t = 0; if (e.offsetParent) { while (e.offsetParent) { t += e.offsetTop; e = e.offsetParent as HTMLElement; } } else if (e.getBoundingClientRect().y) { t += e.getBoundingClientRect().y; } let crumb = document.querySelector("#TopBreadcrumb") as HTMLElement; if (crumb) t -= crumb.offsetHeight; scrollTo(0, t); }; scrollToPostFromHash = () => { if (window.location.hash) { Promise.all(Array.from(document.querySelectorAll("#PostStream img")) .filter(img => !(img as HTMLImageElement).complete) .map(img => new Promise(resolve => { (img as HTMLImageElement).onload = (img as HTMLImageElement).onerror = resolve; }))) .then(() => { let hash = window.location.hash; while (hash.charAt(0) === '#') hash = hash.substring(1); let tag = document.querySelector("div[data-postID='" + hash + "']"); if (tag) { let tagPosition = tag.getBoundingClientRect().top; let crumb = document.querySelector("#ForumContainer #TopBreadcrumb")!; let crumbHeight = crumb.getBoundingClientRect().height; let e = getComputedStyle(document.querySelector(".postItem") as Element); let margin = parseFloat(e.marginTop); let newPosition = tagPosition - crumbHeight - margin; window.scrollBy({ top: newPosition, behavior: 'auto' }); } this.isScrollAdjusted = true; }); } }; setAnswer(postID: number, topicID: number) { var model = { postID: postID, topicID: topicID }; fetch(PopForums.AreaPath + "/Forum/SetAnswer/", { method: "POST", body: JSON.stringify(model), headers: { "Content-Type": "application/json" } }) .then(response => { this.answerPostID = postID; }); } } } ================================================ FILE: src/PopForums.Mvc/Client/State/UserState.ts ================================================ namespace PopForums { export class UserState extends StateBase { constructor() { super(); } private notificationService!: NotificationService; private isLoadingNotifications!: boolean; isPlainText!: boolean; isImageEnabled!: boolean; postImageIds!: Array; userID!: number; lastNotificationDate!: Date; isNotificationEnd!: boolean; @WatchProperty newPmCount!: number; @WatchProperty notificationCount!: number; @WatchProperty notifications!: Array; list!: HTMLElement; async initialize(): Promise { this.postImageIds = new Array(); this.notificationService = new NotificationService(this); await this.notificationService.initialize(); } async LoadNotifications(): Promise { this.isLoadingNotifications = true; this.lastNotificationDate = new Date(2100, 1, 1); this.isNotificationEnd = false; this.notifications = new Array(); await this.notificationService.LoadNotifications(); this.isLoadingNotifications = false; } async MarkRead(contextID: number, notificationType: number) : Promise { await this.notificationService.MarkRead(contextID, notificationType); } async MarkAllRead() : Promise { await this.notificationService.MarkAllRead(); } ScrollLoad = async () => { if (this.isNotificationEnd) return; let streamEnd = (document.querySelector("#NotificationBottom") as HTMLElement); if (!streamEnd) { console.log("Can't find bottom of notifications."); return; } let top = streamEnd.offsetTop; let viewEnd = this.list.scrollTop + this.list.clientHeight; let distance = top - viewEnd; if (!this.isLoadingNotifications && distance < 250 && !this.isNotificationEnd) { await this.LoadMoreNotifications(); } }; private async LoadMoreNotifications() { this.isLoadingNotifications = true; await this.notificationService.LoadNotifications(); this.isLoadingNotifications = false; } } } ================================================ FILE: src/PopForums.Mvc/Client/StateBase.ts ================================================ namespace PopForums { // Properties to watch require the @WatchProperty attribute. export class StateBase { constructor() { this._subs = new Map>(); } private _subs: Map>; subscribe(propertyName: string, eventHandler: Function) { if (!this._subs.has(propertyName)) this._subs.set(propertyName, new Array()); const callbacks = this._subs.get(propertyName)!; callbacks.push(eventHandler); eventHandler(); } notify(propertyName: string) { const callbacks = this._subs.get(propertyName); if (callbacks) for (let i of callbacks) { i(); } } } } ================================================ FILE: src/PopForums.Mvc/Client/WatchPropertyAttribute.ts ================================================ namespace PopForums { export const WatchProperty = (target: any, memberName: string) => { let currentValue: any = target[memberName]; Object.defineProperty(target, memberName, { set(this: any, newValue: any) { currentValue = newValue; this.notify(memberName); }, get() {return currentValue;} }); }; } ================================================ FILE: src/PopForums.Mvc/Client/tsconfig.json ================================================ { "compileOnSave": true, "compilerOptions": { "target": "es2018", "ignoreDeprecations": "6.0", "inlineSources": true, "inlineSourceMap": true, "outFile": "../wwwroot/PopForums.js", "experimentalDecorators": true, "noImplicitAny": true, "moduleResolution": "Node" } } ================================================ FILE: src/PopForums.Mvc/Global.cs ================================================ global using System; global using System.Collections.Generic; global using System.Globalization; global using System.IO; global using System.Linq; global using System.Net; global using System.Reflection; global using System.Security; global using System.Security.Claims; global using System.Text; global using System.Text.Encodings.Web; global using System.Text.Json; global using System.Threading.Tasks; global using Microsoft.AspNetCore.Authentication; global using Microsoft.AspNetCore.Authorization; global using Microsoft.AspNetCore.Builder; global using Microsoft.AspNetCore.DataProtection; global using Microsoft.AspNetCore.Html; global using Microsoft.AspNetCore.Http; global using Microsoft.AspNetCore.Localization; global using Microsoft.AspNetCore.Mvc; global using Microsoft.AspNetCore.Mvc.Controllers; global using Microsoft.AspNetCore.Mvc.Filters; global using Microsoft.AspNetCore.Mvc.ModelBinding; global using Microsoft.AspNetCore.Mvc.Rendering; global using Microsoft.AspNetCore.Mvc.TagHelpers; global using Microsoft.AspNetCore.Mvc.ViewFeatures; global using Microsoft.AspNetCore.Razor.TagHelpers; global using Microsoft.AspNetCore.Routing; global using Microsoft.AspNetCore.SignalR; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Logging; global using PopForums.Configuration; global using PopForums.Email; global using PopForums.Extensions; global using PopForums.ExternalLogin; global using PopForums.Feeds; global using PopForums.Messaging; global using PopForums.Models; global using PopForums.Repositories; global using PopForums.ScoringGame; global using PopForums.Services; global using PopForums.Mvc.Areas.Forums.Authorization; global using PopForums.Mvc.Areas.Forums.Controllers; global using PopForums.Mvc.Areas.Forums.Extensions; global using PopForums.Mvc.Areas.Forums.Messaging; global using PopForums.Mvc.Areas.Forums.Models; global using PopForums.Mvc.Areas.Forums.Services; ================================================ FILE: src/PopForums.Mvc/PopForums.Mvc.csproj ================================================  PopForums Mvc Class Library 22.0.0 Jeff Putz net10.0 PopForums.Mvc PopForums.Mvc true true https://github.com/POPWorldMedia/POPForums https://github.com/POPWorldMedia/POPForums 2025, POP World Media, LLC MIT CompileTypeScript;$(AssignTargetPathsDependsOn) CompileTypeScript;$(DefineStaticWebAssetsDependsOn) CompileTypeScript;$(ResolveStaticWebAssetsInputsDependsOn) /PopForums all runtime; build; native; contentfiles; analyzers; buildtransitive ================================================ FILE: src/PopForums.Mvc/gulpfile.js ================================================ /// var gulp = require("gulp"), merge = require("merge-stream"), babel = require("gulp-babel"), cleancss = require("gulp-clean-css"), uglify = require("gulp-uglify"), sourcemaps = require("gulp-sourcemaps"), rename = require("gulp-rename"), typescript = require("gulp-typescript"); var project = typescript.createProject("Client/tsconfig.json") var nodeRoot = "./node_modules/"; var targetPath = "./wwwroot/lib/"; gulp.task("ts", function () { return project.src().pipe(project()).js.pipe(gulp.dest("wwwroot")); }); gulp.task("copies", function () { var streams = [ gulp.src(nodeRoot + "bootstrap/dist/js/bootstrap.bundle.*").pipe(gulp.dest(targetPath + "/bootstrap/dist/js")), gulp.src(nodeRoot + "bootstrap/dist/css/bootstrap.css").pipe(gulp.dest(targetPath + "/bootstrap/dist/css")), gulp.src(nodeRoot + "bootstrap/dist/css/bootstrap.css.map").pipe(gulp.dest(targetPath + "/bootstrap/dist/css")), gulp.src(nodeRoot + "bootstrap/dist/css/bootstrap.min.css").pipe(gulp.dest(targetPath + "/bootstrap/dist/css")), gulp.src(nodeRoot + "bootstrap/dist/css/bootstrap.min.css.map").pipe(gulp.dest(targetPath + "/bootstrap/dist/css")), gulp.src(nodeRoot + "@microsoft/signalr/dist/browser/**/*").pipe(gulp.dest(targetPath + "/signalr/dist")), gulp.src(nodeRoot + "tinymce/**/*").pipe(gulp.dest(targetPath + "/tinymce")), gulp.src(nodeRoot + "vue/dist/vue.global.*").pipe(gulp.dest(targetPath + "/vue/dist")), gulp.src(nodeRoot + "vue-router/dist/vue-router.global.*").pipe(gulp.dest(targetPath + "/vue-router/dist")), gulp.src(nodeRoot + "axios/dist/**/*").pipe(gulp.dest(targetPath + "/axios/dist")), gulp.src("./wwwroot/Fonts/**/*").pipe(gulp.dest(targetPath + "/PopForums/dist/Fonts")) ]; return merge(streams); }); function jsTask() { return gulp.src("./wwwroot/*.js", { allowEmpty: true }) .pipe(gulp.dest(targetPath + "/PopForums/dist")) .pipe(sourcemaps.init({ loadMaps: true })) .pipe(babel({ presets: ["@babel/preset-env"], sourceMap: true })) .pipe(uglify()) .pipe(rename({ suffix: '.min' })) .pipe(sourcemaps.write("./")) .pipe(gulp.dest(targetPath + "/PopForums/dist")); } function cssTask() { return gulp.src("./wwwroot/*.css", { allowEmpty: true }) .pipe(gulp.dest(targetPath + "/PopForums/dist")) .pipe(sourcemaps.init()) .pipe(cleancss()) .pipe(rename({ suffix: '.min' })) .pipe(sourcemaps.write("./")) .pipe(gulp.dest(targetPath + "/PopForums/dist")); } gulp.task("js", jsTask); gulp.task("css", cssTask); gulp.task("default", gulp.series(["ts","copies","js","css"])); ================================================ FILE: src/PopForums.Mvc/package.json ================================================ { "scripts": {}, "dependencies": { "@microsoft/signalr": "10.0.0", "axios": "1.15.2", "bootstrap": "5.3.8", "tinymce": "8.4.0", "vue": "3.5.33", "vue-router": "5.0.6" }, "devDependencies": { "typescript": "6.0.3", "gulp-typescript": "5.0.1", "@babel/core": "7.28.5", "@babel/preset-env": "7.28.5", "gulp": "4.0.2", "gulp-babel": "8.0.0", "gulp-clean-css": "4.3.0", "gulp-rename": "2.1.0", "gulp-sourcemaps": "3.0.0", "gulp-uglify": "3.0.2", "merge-stream": "2.0.0" }, "babel": { "presets": [ "@babel/preset-env" ] } } ================================================ FILE: src/PopForums.Mvc/wwwroot/Admin.js ================================================ const basePath = "/Forums/AdminApi/"; const Top = { template: "#Top", data() { return { loading: true } }, mounted: function () { }, methods: { setLoading: function (isLoading) { this.loading = isLoading; } } } var loadingMixin = { data() { return { alert: false, message: "" } }, props: { loading: Boolean }, methods: { startLoad: function () { this.$emit("setLoading", true); }, endLoad: function (message) { this.$emit("setLoading", false); if (message) { this.alert = true; this.message = message; var c = this; setTimeout(function () { c.alert = false; }, 5000); } }, errorAlert: function() { alert("There was an error. Please reload admin."); } } } var settingsMixin = { data() { return { settings: {} } }, created: function () { this.startLoad(); axios.get(basePath + "GetSettings").then(response => { this.settings = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); }, methods: { save: function (message) { this.startLoad(); axios.post(basePath + "SaveSettings", this.settings).then(response => { this.settings = response.data; this.endLoad(message); }) .catch(error => this.errorAlert()); } } } const General = { mixins: [settingsMixin, loadingMixin], template: "#General" } const Categories = { mixins: [loadingMixin], template: "#Categories", data() { return { categories: [], newCategory: "", editCategory: "", editID: 0 } }, created: function () { this.startLoad(); axios.get(basePath + "GetCategories").then(response => { this.categories = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); }, methods: { saveNew: function () { this.startLoad(); axios.post(basePath + "AddCategory", { title: this.newCategory }).then(response => { this.categories = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); this.newCategory = ""; }, deleteCat: function (item) { this.startLoad(); axios.post(basePath + "DeleteCategory/" + item.categoryID).then(response => { this.categories = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); }, up: function (item) { this.startLoad(); axios.post(basePath + "MoveCategoryUp/" + item.categoryID).then(response => { this.categories = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); }, down: function (item) { this.startLoad(); axios.post(basePath + "MoveCategoryDown/" + item.categoryID).then(response => { this.categories = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); }, editCat: function (item) { this.editCategory = item.title; this.editID = item.categoryID; const e = this.$refs.modal; var modal = new bootstrap.Modal(e); modal.show(); }, saveCat: function () { this.startLoad(); axios.post(basePath + "EditCategory", { categoryID: this.editID, title: this.editCategory }) .then(response => { this.categories = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); } } } const Forums = { mixins: [loadingMixin], template: "#Forums", data() { return { categories: [], editingForum: null } }, created: function () { this.startLoad(); axios.get(basePath + "GetForums").then(response => { this.categories = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); this.resetForum(); }, methods: { up: function (forum) { this.startLoad(); axios.post(basePath + "MoveForumUp/" + forum.forumID).then(response => { this.categories = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); }, down: function (forum) { this.startLoad(); axios.post(basePath + "MoveForumDown/" + forum.forumID).then(response => { this.categories = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); }, editForum: function (forum) { this.editingForum = forum; if (!this.editingForum.categoryID) this.editingForum.categoryID = 0; const e = this.$refs.modal; var modal = new bootstrap.Modal(e); modal.show(); }, resetForum: function () { this.editingForum = { forumID: 0, title: "", description: "", categoryID: 0, isVisible: true, isArchived: false, isQAForum: false, forumAdapterName: null }; }, newForum: function () { this.resetForum(); const e = this.$refs.modal; var modal = new bootstrap.Modal(e); modal.show(); }, saveForum: function () { this.startLoad(); axios.post(basePath + "SaveForum", this.editingForum) .then(response => { this.categories = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); } } } const ForumPermissions = { mixins: [loadingMixin], template: "#ForumPermissions", data() { return { categories: [], selectedForum: null, allRoles: [], postRoles: [], viewRoles: [], selectedAll: null, selectedPost: null, selectedView: null } }, created: function () { this.startLoad(); axios.get(basePath + "GetForums").then(response => { this.categories = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); this.updatePerm(); }, methods: { forumChange: function () { this.updatePerm(); }, updatePerm: function () { if (this.selectedForum) { this.startLoad(); axios.get(basePath + "GetForumPermissions/" + this.selectedForum).then(response => { this.allRoles = response.data.allRoles; this.viewRoles = response.data.viewRoles; this.postRoles = response.data.postRoles; this.endLoad(); }) .catch(error => this.errorAlert()); } }, modify: function (modifyType, role) { this.startLoad(); axios.post(basePath + "ModifyForumRoles", { forumID: this.selectedForum, modifyType: modifyType, role: role }).then(response => { this.endLoad(); this.updatePerm(); }) .catch(error => this.errorAlert()); } } } const Email = { mixins: [settingsMixin, loadingMixin], template: "#Email" } const Search = { mixins: [settingsMixin, loadingMixin], template: "#Search", data() { return { junkWords: [], selectedWord: null, newWord: "" } }, created: function () { this.updateJunk(); }, methods: { updateJunk: function () { this.startLoad(); axios.get(basePath + "GetJunkWords").then(response => { this.junkWords = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); }, createWord: function () { this.startLoad(); axios.post(basePath + "CreateJunkWord/" + this.newWord).then(response => { this.endLoad(); this.newWord = ""; this.updateJunk(); }) .catch(error => this.errorAlert()); }, deleteWord: function () { this.startLoad(); axios.post(basePath + "DeleteJunkWord/" + this.selectedWord).then(response => { this.endLoad(); this.updateJunk(); }) .catch(error => this.errorAlert()); } } } const ExternalLogins = { mixins: [settingsMixin, loadingMixin], template: "#ExternalLogins" } const RecentUsers = { mixins: [loadingMixin], template: "#RecentUsers", data() { return { results: [] } }, created: function () { this.startLoad(); axios.get(basePath + "GetRecentUsers").then(response => { this.results = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); }, } const EditUser = { mixins: [loadingMixin], template: "#EditUser", data() { return { searchResults: [], searchText: "", searchType: "name", searchAlert: false } }, methods: { search: function () { this.startLoad(); axios.post(basePath + "EditUserSearch", { searchType: this.searchType, searchText: this.searchText }) .then(response => { this.searchResults = response.data; this.endLoad(); if (this.searchResults.length === 0) this.searchAlert = true; else this.searchAlert = false; }) .catch(error => this.errorAlert()); } } } const EditUserDetail = { mixins: [loadingMixin], template: "#EditUserDetail", data() { return { user: { newEmail: "", newPassword: "" }, roles: [] } }, created: function () { var u = basePath + "GetUser/" + this.$route.params.id; axios.get(u).then(response => { this.user = response.data; }) .catch(error => this.errorAlert()); this.startLoad(); axios.get(basePath + "GetAllRoles").then(response => { this.roles = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); }, methods: { saveUser: function (message) { this.startLoad(); axios.post(basePath + "SaveUser", this.user).then(response => { this.endLoad(message); }).catch(error => { alert(error); }); }, uploadAvatar: function (target) { const formData = new FormData(); var files = target.files; formData.append("avatarFile", files[0], files[0].name); axios.post(basePath + "UpdateUserAvatar/" + this.user.userID, formData).then(response => { this.user.avatarID = response.data.avatarID; target.value = ""; }) .catch(error => this.errorAlert()); }, removeAvatar: function () { axios.post(basePath + "UpdateUserAvatar/" + this.user.userID, null).then(response => { this.user.avatarID = response.data.avatarID; }) .catch(error => this.errorAlert()); }, uploadImage: function (target) { const formData = new FormData(); var files = target.files; formData.append("imageFile", files[0], files[0].name); axios.post(basePath + "UpdateUserImage/" + this.user.userID, formData).then(response => { this.user.imageID = response.data.imageID; target.value = ""; }) .catch(error => this.errorAlert()); }, removeImage: function () { axios.post(basePath + "UpdateUserImage/" + this.user.userID, null).then(response => { this.user.imageID = response.data.imageID; }) .catch(error => this.errorAlert()); }, deleteUser: function () { axios.post(basePath + "DeleteUser/" + this.user.userID, null).then(response => { this.$router.push("/edituser"); }) .catch(error => this.errorAlert()); }, deleteAndBanUser: function () { axios.post(basePath + "DeleteAndBanUser/" + this.user.userID, null).then(response => { this.$router.push("/edituser"); }) .catch(error => this.errorAlert()); } } } const UserRoles = { mixins: [loadingMixin], template: "#UserRoles", data() { return { roles: [], newRole: "", selectedAll: null } }, created: function () { this.refreshRoles(); }, methods: { refreshRoles: function () { this.startLoad(); axios.get(basePath + "GetAllRoles").then(response => { this.roles = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); }, createRole: function () { if (this.newRole && this.newRole != "") { this.startLoad(); axios.post(basePath + "CreateRole/" + this.newRole).then(response => { this.endLoad(); this.refreshRoles(); this.newRole = ""; }) .catch(error => this.errorAlert()); } }, deleteRole: function () { if (this.selectedAll) { this.startLoad(); axios.post(basePath + "DeleteRole/" + this.selectedAll).then(response => { this.endLoad(); this.refreshRoles(); }) .catch(error => this.errorAlert()); } } } } const UserImageApproval = { mixins: [loadingMixin], template: "#UserImageApproval", data() { return { isNewUserImageApproved: false, unapproved: [] } }, created: function () { this.refreshData(); }, methods: { refreshData: function () { this.startLoad(); axios.get(basePath + "GetImageApproval").then(response => { this.isNewUserImageApproved = response.data.isNewUserImageApproved; this.unapproved = response.data.unapproved; this.endLoad(); }) .catch(error => this.errorAlert()); }, approveImage: function (id) { this.startLoad(); axios.post(basePath + "ApproveUserImage/" + id).then(response => { this.refreshData(); this.endLoad(); }) .catch(error => this.errorAlert()); }, deleteImage: function (id) { this.startLoad(); axios.post(basePath + "DeleteUserImage/" + id).then(response => { this.refreshData(); this.endLoad(); }) .catch(error => this.errorAlert()); } } } const EmailIpBan = { mixins: [loadingMixin], template: "#EmailIpBan", data() { return { ips: [], emails: [], newEmail: "", newIP: "", selectedIP: null, selectedEmail: null } }, created: function () { this.refreshData(); }, methods: { refreshData: function () { this.startLoad(); axios.get(basePath + "GetEmailIPBan").then(response => { this.emails = response.data.emails; this.ips = response.data.ips; this.endLoad(); }) .catch(error => this.errorAlert()); }, banEmail: function () { this.startLoad(); axios.post(basePath + "BanEmail", { string: this.newEmail }).then(response => { this.endLoad(); this.newEmail = ""; this.refreshData(); }) .catch(error => this.errorAlert()); }, removeEmail: function () { this.startLoad(); axios.post(basePath + "RemoveEmail", { string: this.selectedEmail }).then(response => { this.endLoad(); this.refreshData(); }) .catch(error => this.errorAlert()); }, banIP: function () { this.startLoad(); axios.post(basePath + "BanIP", { string: this.newIP }).then(response => { this.endLoad(); this.newIP = ""; this.refreshData(); }) .catch(error => this.errorAlert()); }, removeIP: function () { this.startLoad(); axios.post(basePath + "RemoveIP", { string: this.selectedIP }).then(response => { this.endLoad(); this.refreshData(); }) .catch(error => this.errorAlert()); } } } const EmailUsers = { mixins: [loadingMixin], template: "#EmailUsers", data() { return { subject: "", body: "", htmlBody: "", errorMessage: "", isErrorVisible: false, isSuccess: false } }, methods: { sendMail: function () { this.isErrorVisible = false; this.startLoad(); axios.post(basePath + "EmailUsers", { subject: this.subject, body: this.body, htmlBody: this.htmlBody }).then(response => { this.endLoad(); this.subject = ""; this.body = ""; this.htmlBody = ""; this.isSuccess = true; }) .catch(e => { this.endLoad(); this.isErrorVisible = true; this.isSuccess = false; this.errorMessage = e.response.data.error; }); } } } const ScoringGame = { mixins: [settingsMixin, loadingMixin], template: "#ScoringGame" } const EventDefinitions = { mixins: [loadingMixin], template: "#EventDefinitions", data() { return { allEvents: [], staticIDs: [], newEvent: { eventDefinitionID: "", description: "", pointValue: "", isPublishedToFeed: false } } }, created: function () { this.refreshData(); }, methods: { refreshData: function () { this.startLoad(); axios.get(basePath + "GetAllEventDefinitions").then(response => { this.allEvents = response.data.allEvents; this.staticIDs = response.data.staticIDs; this.endLoad(); }) .catch(error => this.errorAlert()); }, deleteEvent: function (event) { this.startLoad(); axios.post(basePath + "DeleteEvent/" + event.eventDefinitionID) .then(response => { this.refreshData(); this.endLoad(); }) .catch(error => this.errorAlert()); }, resetEvent: function () { this.newEvent.eventDefinitionID = ""; this.newEvent.description = ""; this.newEvent.pointValue = ""; this.newEvent.isPublishedToFeed = false; }, openNewEvent: function () { this.resetEvent(); const e = this.$refs.modal; var modal = new bootstrap.Modal(e); modal.show(); }, createEvent: function () { this.startLoad(); axios.post(basePath + "CreateEvent", this.newEvent) .then(response => { this.refreshData(); this.endLoad(); }) .catch(error => this.errorAlert()); } } } const AwardDefinitions = { mixins: [loadingMixin], template: "#AwardDefinitions", data() { return { allAwards: [], newAward: { awardDefinitionID: "", title: "", description: "", isSingleTimeAward: false } } }, created: function () { this.refreshData(); }, methods: { refreshData: function () { this.startLoad(); axios.get(basePath + "GetAllAwardDefinitions").then(response => { this.allAwards = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); }, deleteAward: function (award) { this.startLoad(); axios.post(basePath + "DeleteAward/" + award.awardDefinitionID) .then(response => { this.refreshData(); this.endLoad(); }) .catch(error => this.errorAlert()); }, resetAward: function () { this.newAward.awardDefinitionID = ""; this.newAward.title = ""; this.newAward.description = ""; this.newAward.isSingleTimeAward = false; }, openNewAward: function () { this.resetAward(); const e = this.$refs.modal; var modal = new bootstrap.Modal(e); modal.show(); }, createAward: function () { this.startLoad(); axios.post(basePath + "CreateAward", this.newAward) .then(response => { this.refreshData(); this.endLoad(); }) .catch(error => this.errorAlert()); } } } const AwardDefinitionDetail = { mixins: [loadingMixin], template: "#AwardDefinitionDetail", data() { return { award: {}, conditions: [], allEvents: [], newCondition: { awardDefinitionID: 0, eventDefinitionID: "", eventCount: 0 } } }, created: function () { this.refreshData(); }, methods: { refreshData: function () { this.startLoad(); var u = basePath + "GetAward/" + this.$route.params.id; axios.get(u).then(response => { this.award = response.data.award; this.conditions = response.data.conditions; this.allEvents = response.data.allEvents; this.endLoad(); }) .catch(error => this.errorAlert()); }, deleteCondition: function (c) { this.startLoad(); axios.post(basePath + "DeleteCondition", { awardDefinitionID: this.award.awardDefinitionID, eventDefinitionID: c.eventDefinitionID }) .then(response => { this.refreshData(); this.endLoad(); }) .catch(error => this.errorAlert()); }, createCondition: function () { this.newCondition.awardDefinitionID = this.award.awardDefinitionID; this.startLoad(); axios.post(basePath + "CreateCondition", this.newCondition) .then(response => { this.refreshData(); this.endLoad(); }) .catch(error => this.errorAlert()); }, openNewCondition: function () { this.newCondition.eventCount = ""; const e = this.$refs.modal; var modal = new bootstrap.Modal(e); modal.show(); } } } const ManualEvent = { mixins: [loadingMixin], template: "#ManualEvent", data() { return { allEvents: [], searchName: "", searchResults: [], selectedUser: {}, message: "", points: 0, eventDefinitionID: "" } }, created: function () { this.startLoad(); axios.get(basePath + "GetAllEvents") .then(response => { this.allEvents = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); }, methods: { openSearch: function () { const e = this.$refs.modal; var modal = new bootstrap.Modal(e); modal.show(); }, updateList: function () { if (this.searchName.length < 2) return; this.startLoad(); axios.post(basePath + "GetNames", { string: this.searchName }) .then(response => { this.searchResults = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); }, chooseUser: function () { }, createManualEvent: function () { this.startLoad(); axios.post(basePath + "CreateManualEvent", { userID: this.selectedUser.userID, message: this.message, points: this.points }) .then(response => { this.endLoad(); this.message = ""; this.points = 0; }) .catch(error => { alert(error.response.data); this.endLoad(); }); }, createExistingManualEvent: function () { this.startLoad(); axios.post(basePath + "CreateExistingManualEvent", { userID: this.selectedUser.userID, message: this.message, points: null, eventDefinitionID: this.eventDefinitionID }) .then(response => { this.endLoad(); this.message = ""; }) .catch(error => { alert(error.response.data); this.endLoad(); }); } } } const IPHistory = { mixins: [loadingMixin], template: "#IPHistory", data() { return { history: [], query: { iP: "", start: "", end: "" } } }, methods: { getHistory: function () { this.startLoad(); var copy = Object.assign({}, this.query); copy.start = new Date(this.query.start).toISOString(); copy.end = new Date(this.query.end).toISOString(); axios.post(basePath + "QueryIPHistory", copy) .then(response => { this.history = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); } } } const SecurityLog = { mixins: [loadingMixin], template: "#SecurityLog", data() { return { history: [], query: { searchTerm: "", type: "Name", start: "", end: "" } } }, methods: { getHistory: function () { this.startLoad(); var copy = Object.assign({}, this.query); copy.start = new Date(this.query.start).toISOString(); copy.end = new Date(this.query.end).toISOString(); axios.post(basePath + "QuerySecurityLog", copy) .then(response => { this.history = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); } } } const ModerationLog = { mixins: [loadingMixin], template: "#ModerationLog", data() { return { history: [], query: { start: "", end: "" } } }, methods: { getHistory: function () { this.startLoad(); var copy = Object.assign({}, this.query); copy.start = new Date(this.query.start).toISOString(); copy.end = new Date(this.query.end).toISOString(); axios.post(basePath + "QueryModerationLog", copy) .then(response => { this.history = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); } } } const ErrorLog = { mixins: [loadingMixin], template: "#ErrorLog", data() { return { errorList: { pageIndex: 1, pageSize: 20, list: [] } } }, created: function () { this.getErrors(); }, methods: { getMore: function (newIndex) { this.errorList.pageIndex = newIndex; this.getErrors(); }, getErrors: function () { this.startLoad(); axios.get(basePath + "GetErrorLog/" + this.errorList.pageIndex) .then(response => { this.errorList = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); }, deleteAll() { this.startLoad(); axios.post(basePath + "DeleteAllErrors") .then(response => { this.errorList.pageIndex = 1; this.endLoad(); this.getErrors(); }) .catch(error => this.errorAlert()); } } } const Services = { mixins: [loadingMixin], template: "#Services", data() { return { list: {} } }, created: function () { this.getData(); }, methods: { getData: function () { this.startLoad(); axios.get(basePath + "GetServices") .then(response => { this.list = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); }, clearAll: function () { this.startLoad(); axios.post(basePath + "ClearServices") .then(response => { this.list = response.data; this.endLoad(); }) .catch(error => this.errorAlert()); } } } const routes = [ { path: "/", component: Top, redirect: "/general", children: [ { path: "/general", component: General }, { path: "/categories", component: Categories }, { path: "/forums", component: Forums }, { path: "/forumpermissions", component: ForumPermissions }, { path: "/email", component: Email }, { path: "/search", component: Search }, { path: "/externallogins", component: ExternalLogins }, { path: "/recentusers", component: RecentUsers }, { path: "/edituser", component: EditUser }, { path: "/edituser/:id", component: EditUserDetail }, { path: "/userroles", component: UserRoles }, { path: "/userimageapproval", component: UserImageApproval }, { path: "/emailipban", component: EmailIpBan }, { path: "/emailusers", component: EmailUsers }, { path: "/scoringgame", component: ScoringGame }, { path: "/eventdefinitions", component: EventDefinitions }, { path: "/awarddefinitions", component: AwardDefinitions }, { path: "/awarddefinitions/:id", component: AwardDefinitionDetail }, { path: "/manualevent", component: ManualEvent }, { path: "/iphistory", component: IPHistory }, { path: "/securitylog", component: SecurityLog }, { path: "/moderationlog", component: ModerationLog }, { path: "/errorlog", component: ErrorLog }, { path: "/services", component: Services } ] }, { path: "/:pathMatch(.*)*", redirect: "/general" } ]; const router = VueRouter.createRouter({ caseSensitive: false, routes: routes, history: VueRouter.createWebHistory("/forums/admin") }); const app = Vue.createApp({}) app.use(router); app.mount("#app"); ================================================ FILE: src/PopForums.Mvc/wwwroot/Editor.css ================================================ body { margin: 10px; } blockquote { font-size: 100%; border-left: 5px solid #aaaaaa; padding: 0 1em; } img { max-width: 100%; } ================================================ FILE: src/PopForums.Mvc/wwwroot/PopForums.css ================================================ #ForumContainer h1 .icon { font-size: .8em; } #ForumContainer .navbar { z-index: 1025; } #ForumContainer #PostResponseMessage, #ForumContainer #TopicModerationLog { display: none; } #ForumContainer #NotificationList .card-footer { font-size: .8em; } #ForumContainer .toLabelX { cursor: pointer; } #ForumContainer #ToModal #ToResultList { height: 200px; overflow-y: scroll; margin-top: 10px; } #ForumContainer #PMToBox div { margin-right: 5px; } #ForumContainer .answerData { min-width: 4em; } #ForumContainer .answerButton, pf-notificationtoggle, #ForumContainer .postUserData h3 { cursor: pointer; } #ForumContainer .QAstate { width: 3em; } #ForumContainer .newPostBlock { border-left: solid 8px; border-color: var(--bs-secondary); padding-left: 10px; } /* post item */ #ForumContainer blockquote { font-size: 100%; border-left: 8px solid var(--bs-secondary); padding: 0 1em; } #ForumContainer .miniProfileBox { height: 0; overflow: hidden; transition: all .5s ease-in-out; } .voters { max-height: 10em; overflow: auto; } .voters ul { padding: 0; margin: 0; } .voters li { list-style: none;font-size: 100%; } pf-votecount div span { font-size: 1.2em; margin-right: 1em; cursor: pointer; } #ForumContainer .voteUpContainer { width: 6.5em; } #ForumContainer .voteUpContainerVert { height: 6.5em; } #ForumContainer .postToolContainer { clear: both; } #ForumContainer .postToolContainer .toolButton { width: 2em; text-align: center; font-size: 1.3em; margin: auto 0; } #ForumContainer .postToolContainer a, #ForumContainer .postToolContainer button { color: var(--bs-body-color); } #ForumContainer .postToolContainer *, #ForumContainer h2 a { text-decoration: none; cursor: pointer; vertical-align: middle; } #ForumContainer .postItem .postImage { max-width: 100%;height: auto; } #ForumContainer .postItem iframe { max-width: 100%; } #ForumContainer .postTime { display: block; } /* text editor */ #ForumContainer #FullText { height: 300px; } #tinymce { padding: 10px !important; } #ForumContainer .postForm { margin: 10px 0; } #ForumContainer #ParsedFullText { overflow-y: scroll;overflow-x: hidden; max-height: 350px; } #ForumContainer .voteCount .voters { display: none; } #ForumContainer .morePostsButton { margin-bottom: 10px; } #ForumContainer .morePager:hover, #ForumContainer .currentPager:hover { background: none; } #ForumContainer .morePager, #ForumContainer .currentPager .page-link { cursor: default; white-space: nowrap; } #ForumContainer .modal-body img { max-width: 100%; } /* queries */ @media(min-width:1px) { #ForumContainer #PreviewModal .modal-dialog { width: 90%!important; max-height: 95% !important; margin: 10px auto; } } /* Bootstrap overrides */ #ForumContainer .navbar .badge { background-color: var(--bs-danger); color: var(--bs-white); position: relative; top: -2px; } #ForumContainer .explode { animation: explode 0.5s; animation-fill-mode: forwards; } @keyframes explode { 45% { transform: scale(2, 2); } 55% { transform: scale(2, 2); } 50% { background-color: var(--bs-light); } } #ForumContainer .hidden { display: none !important; } #ForumContainer .breadcrumb { padding: .75em; background-color: var(--bs-light); } #ForumContainer .breadcrumb a { text-decoration: none; } .btn-link { border: none; cursor: pointer; background: none; } /* icons */ .miniProfile a.icon { text-decoration: none !important; margin-right: .5em; } :root { --bs-light-rgb: 240, 240, 240; --bs-light: #f0f0f0; } .alert-light { --bs-alert-bg: #f0f0f0; } #AccountBox .icon, #SearchDropDown .icon { font-size: 1.3em; vertical-align: text-top; margin-left: 0.2em; } @font-face { font-family: 'icomoon'; src: url('Fonts/icomoon.ttf?rv0tpv') format('truetype'), url('Fonts/icomoon.woff?rv0tpv') format('woff'), url('Fonts/icomoon.svg?rv0tpv#icomoon') format('svg'); font-weight: normal; font-style: normal; font-display: block; } .icon { /* use !important to prevent issues with browser extensions that change fonts */ font-family: 'icomoon' !important; speak: none; font-style: normal; font-weight: normal; font-variant: normal; text-transform: none; line-height: 1; /* Better Font Rendering =========== */ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .icon-recycle:before { content: "\e94c"; } .icon-trophy:before { content: "\e900"; } .icon-trophy-fill:before { content: "\e901"; } .icon-bell-fill:before { content: "\e902"; } .icon-bell:before { content: "\e903"; } .icon-bell-slash:before { content: "\e904"; } .icon-card-list:before { content: "\e905"; } .icon-quote:before { content: "\e907"; } .icon-link:before { content: "\e908"; } .icon-arrow-counterclockwise:before { content: "\e909"; } .icon-arrow-down-circle:before { content: "\e90a"; } .icon-arrow-left-circle:before { content: "\e90b"; } .icon-arrow-right-circle:before { content: "\e90c"; } .icon-arrow-up-circle:before { content: "\e90d"; } .icon-box-arrow-up-right:before { content: "\e90e"; } .icon-chat:before { content: "\e90f"; } .icon-chat-dots:before { content: "\e910"; } .icon-check-circle:before { content: "\e911"; } .icon-check-circle-fill:before { content: "\e912"; } .icon-clock-history:before { content: "\e913"; } .icon-cloud-arrow-up:before { content: "\e914"; } .icon-cloud-arrow-up-fill:before { content: "\e915"; } .icon-dash-circle:before { content: "\e916"; } .icon-envelope:before { content: "\e917"; } .icon-exclamation-circle-fill:before { content: "\e918"; } .icon-exclamation-octagon-fill:before { content: "\e919"; } .icon-eye:before { content: "\e91a"; } .icon-eye-fill:before { content: "\e91b"; } .icon-facebook:before { content: "\e91c"; } .icon-file-earmark-text:before { content: "\e91d"; } .icon-file-earmark-text-fill:before { content: "\e91e"; } .icon-file-text:before { content: "\e91f"; } .icon-file-text-fill:before { content: "\e920"; } .icon-gear-fill:before { content: "\e921"; } .icon-google:before { content: "\e922"; } .icon-hand-thumbs-down:before { content: "\e923"; } .icon-hand-thumbs-down-fill:before { content: "\e924"; } .icon-hand-thumbs-up:before { content: "\e925"; } .icon-hand-thumbs-up-fill:before { content: "\e926"; } .icon-heart:before { content: "\e927"; } .icon-heart-fill:before { content: "\e928"; } .icon-house-door:before { content: "\e929"; } .icon-image:before { content: "\e92a"; } .icon-images:before { content: "\e92b"; } .icon-info-circle:before { content: "\e92c"; } .icon-info-circle-fill:before { content: "\e92d"; } .icon-instagram:before { content: "\e92e"; } .icon-lock:before { content: "\e92f"; } .icon-lock-fill:before { content: "\e930"; } .icon-microsoft:before { content: "\e931"; } .icon-paperclip:before { content: "\e932"; } .icon-pencil-square:before { content: "\e933"; } .icon-people:before { content: "\e934"; } .icon-people-fill:before { content: "\e935"; } .icon-person:before { content: "\e936"; } .icon-person-fill:before { content: "\e937"; } .icon-pin-angle:before { content: "\e938"; } .icon-pin-angle-fill:before { content: "\e939"; } .icon-plus-square:before { content: "\e93a"; } .icon-plus-square-fill:before { content: "\e93b"; } .icon-question-circle:before { content: "\e93c"; } .icon-question-circle-fill:before { content: "\e93d"; } .icon-reply:before { content: "\e93e"; } .icon-reply-fill:before { content: "\e93f"; } .icon-rss-fill:before { content: "\e940"; } .icon-search:before { content: "\e941"; } .icon-share-fill:before { content: "\e942"; } .icon-skip-backward-fill:before { content: "\e943"; } .icon-skip-end-fill:before { content: "\e944"; } .icon-skip-forward-fill:before { content: "\e945"; } .icon-skip-start-fill:before { content: "\e946"; } .icon-star:before { content: "\e947"; } .icon-star-fill:before { content: "\e948"; } .icon-trash3-fill:before { content: "\e949"; } .icon-unlock-fill:before { content: "\e94a"; } .icon-youtube:before { content: "\e94b"; } #ForumContainer .newIndicator a { text-decoration: none; } #ForumContainer .topicIndicator { font-size: 2em; width: 40px; } #ForumContainer .topicIndicator .topicIndicatorBadge { position: relative; font-size: 60%; text-shadow: -2px 0 white, 0 2px white, 2px 0 white, 0 -2px white; } #ForumContainer .topicIndicator .soloLeftBadge { left: -95%; } #ForumContainer .topicIndicator .firstBadge { left: -25%; } #ForumContainer .topicIndicator .secondBadge { left: -145%; } #ForumContainer #Profile div.row div { padding:.5em; } ================================================ FILE: src/PopForums.Sql/CacheHelper.cs ================================================ namespace PopForums.Sql; public class CacheHelper : ICacheHelper { public CacheHelper(IConfig config, ITenantService tenantService) { _config = config; _tenantService = tenantService; if (_cache == null) { var options = new MemoryCacheOptions(); _cache = new MemoryCache(options); } } private readonly IConfig _config; private readonly ITenantService _tenantService; private static IMemoryCache _cache; private string PrefixTenantOnKey(string key) { var tenantID = _tenantService.GetTenant(); return $"{tenantID}:{key}"; } public void SetCacheObject(string key, object value) { key = PrefixTenantOnKey(key); var options = new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(_config.CacheSeconds) }; _cache.Set(key, value, options); } public void SetCacheObject(string key, object value, double seconds) { key = PrefixTenantOnKey(key); var options = new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(seconds) }; _cache.Set(key, value, options); } public void SetLongTermCacheObject(string key, object value) { key = PrefixTenantOnKey(key); var options = new MemoryCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(60) }; _cache.Set(key, value, options); } public void SetPagedListCacheObject(string rootKey, int page, List value) { rootKey = PrefixTenantOnKey(rootKey); _cache.TryGetValue(rootKey, out Dictionary> rootPages); if (rootPages == null) rootPages = new Dictionary>(); else if (rootPages.ContainsKey(page)) rootPages.Remove(page); rootPages.Add(page, value); var options = new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(_config.CacheSeconds) }; _cache.Set(rootKey, rootPages, options); } public void RemoveCacheObject(string key) { key = PrefixTenantOnKey(key); _cache.Remove(key); OnRemoveCacheKey?.Invoke(key); } public T GetCacheObject(string key) { key = PrefixTenantOnKey(key); var cacheObject = _cache.Get(key); return cacheObject != null ? (T)cacheObject : default; } public List GetPagedListCacheObject(string rootKey, int page) { rootKey = PrefixTenantOnKey(rootKey); _cache.TryGetValue(rootKey, out Dictionary> rootPages); if (rootPages == null) return null; if (rootPages.ContainsKey(page)) return rootPages[page]; return null; } public event Action OnRemoveCacheKey; public string GetEffectiveCacheKey(string key) { var effectiveKey = PrefixTenantOnKey(key); return effectiveKey; } } ================================================ FILE: src/PopForums.Sql/Extensions.cs ================================================ namespace PopForums.Sql; public static class Extensions { public static DbCommand Command(this DbConnection connection, ISqlObjectFactory sqlObjectFactory, string sql) { var command = sqlObjectFactory.GetCommand(sql, connection); return command; } public static DbCommand AddParameter(this DbCommand command, ISqlObjectFactory sqlObjectFactory, string parameterName, object value) { var parameter = sqlObjectFactory.GetParameter(parameterName, value); command.Parameters.Add(parameter); return command; } public static void Using(this DbConnection connection, Action action) { using (connection) { try { connection.Open(); action(connection); } finally { connection.Close(); connection.Dispose(); } } } public static async Task UsingAsync(this DbConnection connection, Func action) { await using (connection) { try { await connection.OpenAsync(); await action(connection); } finally { await connection.CloseAsync(); connection.Dispose(); } } } public static void AddPopForumsSql(this IServiceCollection services) { services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); } public static object GetObjectOrDbNull(this object value) { return value ?? DBNull.Value; } public static int? NullIntDbHelper(this DbDataReader reader, int index) { if (reader.IsDBNull(index)) return null; return reader.GetInt32(index); } public static string NullStringDbHelper(this DbDataReader reader, int index) { if (reader.IsDBNull(index)) return null; return reader.GetString(index); } public static Guid? NullGuidDbHelper(this IDataReader reader, int index) { if (reader.IsDBNull(index)) return null; return reader.GetGuid(index); } public static string NullToEmpty(this string text) { if (String.IsNullOrEmpty(text)) return String.Empty; return text; } } ================================================ FILE: src/PopForums.Sql/Global.cs ================================================ global using System; global using System.Collections.Generic; global using System.Data; global using System.Data.Common; global using System.IO; global using System.Linq; global using System.Reflection; global using System.Text; global using System.Text.RegularExpressions; global using System.Text.Json; global using System.Threading.Tasks; global using Microsoft.Data.SqlClient; global using Microsoft.Extensions.Caching.Memory; global using Microsoft.Extensions.DependencyInjection; global using Dapper; global using PopForums.Configuration; global using PopForums.Email; global using PopForums.ExternalLogin; global using PopForums.Models; global using PopForums.Repositories; global using PopForums.ScoringGame; global using PopForums.Services; global using PopForums.Sql.Repositories; ================================================ FILE: src/PopForums.Sql/ISqlObjectFactory.cs ================================================ namespace PopForums.Sql; public interface ISqlObjectFactory { DbConnection GetConnection(); DbCommand GetCommand(); DbCommand GetCommand(string sql); DbCommand GetCommand(string sql, DbConnection connection); DbParameter GetParameter(string parameterName, object value); } ================================================ FILE: src/PopForums.Sql/JsonElementTypeHandler.cs ================================================ namespace PopForums.Sql; public class JsonElementTypeHandler : SqlMapper.TypeHandler { public override void SetValue(IDbDataParameter parameter, JsonElement value) { parameter.DbType = DbType.String; parameter.Size = int.MaxValue; parameter.Value = value.ToString(); } public override JsonElement Parse(object value) { var o = JsonSerializer.Deserialize((string)value); var element = JsonSerializer.SerializeToElement(o, new JsonSerializerOptions {PropertyNamingPolicy = JsonNamingPolicy.CamelCase}); return element; } } ================================================ FILE: src/PopForums.Sql/PopForums.Sql.csproj ================================================  PopForums.Data.Sql Class Library 22.0.0 Jeff Putz net10.0 PopForums.Sql PopForums.Sql true https://github.com/POPWorldMedia/POPForums https://github.com/POPWorldMedia/POPForums 2025, POP World Media, LLC MIT ================================================ FILE: src/PopForums.Sql/PopForums.sql ================================================ -- ******************************************************** pf_PopForumsUser CREATE TABLE [dbo].[pf_PopForumsUser]( [UserID] [int] IDENTITY(1,1) NOT NULL PRIMARY KEY CLUSTERED, [Name] [nvarchar](256) NOT NULL, [Email] [nvarchar](256) NOT NULL, [CreationDate] [datetime] NOT NULL, [IsApproved] [bit] NOT NULL DEFAULT ((-1)), [Password] [nvarchar](256) NOT NULL, [AuthorizationKey] [uniqueidentifier] NOT NULL DEFAULT ('00000000-0000-0000-0000-000000000000'), [Salt] [uniqueidentifier] NULL, [TokenExpiration] [datetime] NULL ); CREATE UNIQUE NONCLUSTERED INDEX [IX_PopForumsUser_UserName] ON [dbo].[pf_PopForumsUser]([Name]); CREATE UNIQUE NONCLUSTERED INDEX [IX_PopForumsUser_Email] ON [dbo].[pf_PopForumsUser]([Email]); -- ******************************************************** pf_Profile CREATE TABLE [dbo].[pf_Profile]( [UserID] [int] NOT NULL PRIMARY KEY, [IsSubscribed] [bit] NOT NULL DEFAULT ((0)), [Signature] nvarchar(MAX) NOT NULL, [ShowDetails] [bit] NOT NULL DEFAULT ((0)), [Location] [nvarchar](256) NOT NULL, [IsPlainText] [bit] NOT NULL DEFAULT ((0)), [DOB] [datetime] NULL, [Web] [nvarchar](256) NOT NULL, [Facebook] [nvarchar](256) NULL, [Instagram] [nvarchar](256) NULL, [IsTos] [bit] NOT NULL DEFAULT ((0)), [AvatarID] [int] NULL, [ImageID] [int] NULL, [HideVanity] [bit] NOT NULL DEFAULT ((0)), [LastPostID] [int] NULL, [Points] [int] NOT NULL DEFAULT (0), [IsAutoFollowOnReply] [bit] NOT NULL DEFAULT(1) ); ALTER TABLE [dbo].[pf_Profile] WITH CHECK ADD CONSTRAINT [FK_pf_Profile_pf_PopForumsUser] FOREIGN KEY([UserID]) REFERENCES [dbo].[pf_PopForumsUser] ([UserID]) ON DELETE CASCADE; ALTER TABLE [dbo].[pf_Profile] CHECK CONSTRAINT [FK_pf_Profile_pf_PopForumsUser]; -- ******************************************************** pf_UserActivity CREATE TABLE [dbo].[pf_UserActivity]( [UserID] [int] NOT NULL PRIMARY KEY CLUSTERED, [LastActivityDate] [datetime] NOT NULL, [LastLoginDate] [datetime] NOT NULL, [RefreshToken] [nvarchar](MAX) NULL ); ALTER TABLE [dbo].[pf_UserActivity] WITH CHECK ADD CONSTRAINT [FK_pf_UserActivity_pf_PopForumsUser] FOREIGN KEY([UserID]) REFERENCES [dbo].[pf_PopForumsUser] ([UserID]) ON DELETE CASCADE; ALTER TABLE [dbo].[pf_UserActivity] CHECK CONSTRAINT [FK_pf_UserActivity_pf_PopForumsUser]; -- ******************************************************** pf_EmailBan CREATE TABLE [dbo].[pf_EmailBan] ( [EmailBan] [nvarchar] (256) NOT NULL PRIMARY KEY ); -- ******************************************************** pf_IPBan CREATE TABLE [dbo].[pf_IPBan] ( [IPBan] [nvarchar] (256) NOT NULL PRIMARY KEY ); -- ******************************************************** pf_Role and pf_PopForumsUserRole CREATE TABLE [dbo].[pf_Role] ( [Role] [nvarchar] (256) NOT NULL PRIMARY KEY ); CREATE TABLE [dbo].[pf_PopForumsUserRole] ( [UserID] [int] NOT NULL , [Role] [nvarchar] (256) NOT NULL ); CREATE CLUSTERED INDEX [IX_PopForumsUserRole_UserID] ON [dbo].[pf_PopForumsUserRole]([UserID]); ALTER TABLE [dbo].[pf_PopForumsUserRole] WITH CHECK ADD CONSTRAINT [FK_pf_PopForumsUserRole_Role] FOREIGN KEY([Role]) REFERENCES [dbo].[pf_Role] ([Role]) ON DELETE CASCADE; ALTER TABLE [dbo].[pf_PopForumsUserRole] CHECK CONSTRAINT [FK_pf_PopForumsUserRole_Role]; ALTER TABLE [dbo].[pf_PopForumsUserRole] WITH CHECK ADD CONSTRAINT [FK_pf_PopForumsUserRole_UserID] FOREIGN KEY([UserID]) REFERENCES [dbo].[pf_PopForumsUser] ([UserID]) ON DELETE CASCADE; ALTER TABLE [dbo].[pf_PopForumsUserRole] CHECK CONSTRAINT [FK_pf_PopForumsUserRole_UserID]; INSERT INTO pf_Role (Role) VALUES ('Admin'); INSERT INTO pf_Role (Role) VALUES ('Moderator'); -- ******************************************************** pf_SecurityLog CREATE TABLE [dbo].[pf_SecurityLog]( [SecurityLogID] [int] IDENTITY(1,1) NOT NULL PRIMARY KEY, [SecurityLogType] [int] NOT NULL, [UserID] [int] NULL, [TargetUserID] [int] NULL, [IP] [nvarchar](40) NOT NULL, [Message] [nvarchar](256) NOT NULL, [ActivityDate] [datetime] NOT NULL ); CREATE NONCLUSTERED INDEX [IX_pf_SecurityLog_IP_ActivityDate] ON [dbo].[pf_SecurityLog] ( [IP] ASC, [ActivityDate] DESC ); CREATE NONCLUSTERED INDEX [IX_pf_SecurityLog_UserID_ActivityDate] ON [dbo].[pf_SecurityLog] ( [UserID] ASC, [ActivityDate] DESC ); CREATE NONCLUSTERED INDEX [IX_pf_SecurityLog_TargetUserID_ActivityDate] ON [dbo].[pf_SecurityLog] ( [TargetUserID] ASC, [ActivityDate] DESC ); CREATE NONCLUSTERED INDEX IX_pf_SecurityLog_TargetUserID_SecurityLogType ON pf_SecurityLog ( TargetUserID DESC, SecurityLogType ); -- ******************************************************** pf_Category CREATE TABLE [dbo].[pf_Category]( [CategoryID] [int] IDENTITY(1,1) NOT NULL PRIMARY KEY, [Title] [nvarchar](256) NOT NULL, [SortOrder] [int] NOT NULL ); -- ******************************************************** pf_Forum CREATE TABLE [dbo].[pf_Forum]( [ForumID] [int] IDENTITY(1,1) NOT NULL PRIMARY KEY, [CategoryID] [int] NULL, [Title] [nvarchar](256) NOT NULL, [Description] [nvarchar](256) NOT NULL, [IsVisible] [bit] NOT NULL, [IsArchived] [bit] NOT NULL, [SortOrder] [int] NOT NULL, [TopicCount] [int] NOT NULL, [PostCount] [int] NOT NULL, [LastPostTime] [datetime] NOT NULL, [LastPostName] [nvarchar](256) NOT NULL, [UrlName] [nvarchar](256) NOT NULL, [ForumAdapterName] [varchar](256) NULL, [IsQAForum] [bit] NOT NULL DEFAULT ((0)) ); CREATE UNIQUE NONCLUSTERED INDEX [IX_pf_Forum_UrlName] ON [dbo].[pf_Forum] ( [UrlName] ASC ); -- ******************************************************** pf_Topic CREATE TABLE [dbo].[pf_Topic]( [TopicID] [int] IDENTITY(1,1) NOT NULL, [ForumID] [int] NOT NULL DEFAULT (0), [Title] [nvarchar](256) NOT NULL, [ReplyCount] [int] NOT NULL, [ViewCount] [int] NOT NULL, [StartedByUserID] [int] NOT NULL, [StartedByName] [nvarchar](256) NOT NULL, [LastPostUserID] [int] NOT NULL, [LastPostName] [nvarchar](256) NOT NULL, [LastPostTime] [datetime] NOT NULL, [IsClosed] [bit] NOT NULL, [IsPinned] [bit] NOT NULL, [IsDeleted] [bit] NOT NULL, [UrlName] [nvarchar](256) NOT NULL, [AnswerPostID] [int] NULL, CONSTRAINT [PK_pf_Topic] PRIMARY KEY NONCLUSTERED ( [TopicID] ASC ) ); CREATE CLUSTERED INDEX [IX_pf_Topic_ForumID] ON [dbo].[pf_Topic] ( [ForumID] ASC, [IsPinned] DESC, [LastPostTime] DESC ); ALTER TABLE [dbo].[pf_Topic] WITH CHECK ADD CONSTRAINT [FK_pf_Topic_pf_Forum] FOREIGN KEY([ForumID]) REFERENCES [dbo].[pf_Forum] ([ForumID]) ON DELETE CASCADE; CREATE UNIQUE NONCLUSTERED INDEX [IX_pf_Topic_UrlName] ON [dbo].[pf_Topic] ( [UrlName] ASC ); CREATE NONCLUSTERED INDEX [IX_pf_Topic_LastPostTime] ON [dbo].[pf_Topic] ( [LastPostTime] DESC ); -- ******************************************************** pf_Post CREATE TABLE [dbo].[pf_Post]( [PostID] [int] IDENTITY(1,1) NOT NULL PRIMARY KEY NONCLUSTERED, [TopicID] [int] NOT NULL DEFAULT (0), [ParentPostID] [int] NOT NULL DEFAULT (0), [IP] [nvarchar](40) NOT NULL, [IsFirstInTopic] [bit] NOT NULL, [ShowSig] [bit] NOT NULL, [UserID] [int] NOT NULL, [Name] [nvarchar](256) NOT NULL, [Title] [nvarchar](256) NOT NULL, [FullText] nvarchar(MAX) NOT NULL, [PostTime] [datetime] NOT NULL, [IsEdited] [bit] NOT NULL, [LastEditName] [nvarchar](256) NOT NULL, [LastEditTime] [datetime] NULL, [IsDeleted] [bit] NOT NULL, [Votes] [int] DEFAULT 0 NOT NULL ); CREATE CLUSTERED INDEX [IX_pf_Post_TopicID] ON [dbo].[pf_Post] ( [TopicID] ASC, [PostTime] ASC ); CREATE NONCLUSTERED INDEX [IX_pf_Post_PostTime] ON [dbo].[pf_Post] ( [PostTime] DESC ); ALTER TABLE [dbo].[pf_Post] WITH CHECK ADD CONSTRAINT [FK_pf_Post_pf_Topic] FOREIGN KEY([TopicID]) REFERENCES [dbo].[pf_Topic] ([TopicID]) ON DELETE CASCADE; CREATE NONCLUSTERED INDEX [IX_pf_Post_UserID] ON [dbo].[pf_Post] ( [UserID] ASC, [IsDeleted] ASC ); CREATE NONCLUSTERED INDEX [IX_pf_Post_IP_PostTime] ON [dbo].[pf_Post] ( [IP] ASC, [PostTime] DESC ); -- ******************************************************** pf_Setting CREATE TABLE [dbo].[pf_Setting] ( [Setting] [nvarchar] (256) NOT NULL PRIMARY KEY, [Value] nvarchar(MAX) NOT NULL ); -- ******************************************************** pf_QueuedEmailMessage CREATE TABLE [dbo].[pf_QueuedEmailMessage] ( [MessageID] [int] IDENTITY (1, 1) NOT NULL, [FromName] [nvarchar] (256) NOT NULL, [ToEmail] [nvarchar] (256) NOT NULL, [ToName] [nvarchar] (256) NOT NULL, [Subject] [nvarchar] (256) NOT NULL, [Body] nvarchar(MAX) NOT NULL, [HtmlBody] nvarchar(MAX) NULL, [QueueTime] [datetime] NOT NULL ); ALTER TABLE [dbo].[pf_QueuedEmailMessage] ADD CONSTRAINT [PK_pf_QueuedEmailMessage] PRIMARY KEY CLUSTERED ( [MessageID] ASC ); CREATE NONCLUSTERED INDEX [IX_pf_QueuedEmailMessage_QueueTime] ON [dbo].[pf_QueuedEmailMessage] ( [QueueTime] ASC ); -- ******************************************************** pf_ErrorLog CREATE TABLE [dbo].[pf_ErrorLog] ( [ErrorID] [int] IDENTITY (1, 1) NOT NULL PRIMARY KEY CLUSTERED, [TimeStamp] [datetime] NULL , [Message] nvarchar(MAX) NOT NULL , [StackTrace] nvarchar(MAX) NOT NULL , [Data] nvarchar(MAX) NOT NULL , [Severity] [int] NOT NULL ); -- ******************************************************** pf_ModerationLog CREATE TABLE [dbo].[pf_ModerationLog]( [ModerationID] [int] IDENTITY(1,1) NOT NULL PRIMARY KEY CLUSTERED, [TimeStamp] [datetime] NOT NULL, [UserID] [int] NOT NULL, [UserName] [nvarchar](256) NOT NULL, [ModerationType] [int] NOT NULL, [ForumID] [int] NULL, [TopicID] [int] NOT NULL, [PostID] [int] NULL, [Comment] nvarchar(MAX) NOT NULL, [OldText] nvarchar(MAX) NULL ); CREATE NONCLUSTERED INDEX [IX_pf_ModerationLog_TopicID] ON [dbo].[pf_ModerationLog] ( [TopicID] ASC ); CREATE NONCLUSTERED INDEX [IX_pf_ModerationLog_PostID] ON [dbo].[pf_ModerationLog] ( [PostID] ASC ); -- ******************************************************** pf_UserSession CREATE TABLE [dbo].[pf_UserSession] ( [SessionID] [int] NOT NULL, [UserID] [int] NULL, [LastTime] [datetime] NOT NULL ); CREATE CLUSTERED INDEX [IX_pf_UserSession_SessionID] ON [dbo].[pf_UserSession] ( [SessionID] ASC ); CREATE NONCLUSTERED INDEX [IX_pf_UserSession_UserID] ON [dbo].[pf_UserSession] ( [UserID] ASC ); -- ******************************************************** pf_LastForumView CREATE TABLE [dbo].[pf_LastForumView]( [UserID] [int] NOT NULL, [ForumID] [int] NOT NULL, [LastForumViewDate] [datetime] NOT NULL ); CREATE CLUSTERED INDEX [IX_pf_LastForumView_UserID_ForumID] ON [dbo].[pf_LastForumView] ( [UserID] ASC, [ForumID] ASC ); ALTER TABLE [dbo].[pf_LastForumView] WITH CHECK ADD CONSTRAINT [FK_pf_LastForumView_ForumID] FOREIGN KEY([ForumID]) REFERENCES [dbo].[pf_Forum] ([ForumID]) ON DELETE CASCADE; ALTER TABLE [dbo].[pf_LastForumView] WITH CHECK ADD CONSTRAINT [FK_pf_LastForumView_UserID] FOREIGN KEY([UserID]) REFERENCES [dbo].[pf_PopForumsUser] ([UserID]) ON DELETE CASCADE; -- ******************************************************** pf_UserImages CREATE TABLE [dbo].[pf_UserImages]( [UserImageID] [int] IDENTITY(1,1) NOT NULL PRIMARY KEY NONCLUSTERED, [UserID] [int] NOT NULL, [SortOrder] [int] NOT NULL, [IsApproved] [bit] NOT NULL, [TimeStamp] [datetime] NOT NULL, [ImageData] varbinary(MAX) NOT NULL ); CREATE CLUSTERED INDEX [IX_UserImages_UserID] ON [dbo].[pf_UserImages] ( [UserID] ASC ); ALTER TABLE [dbo].[pf_UserImages] WITH CHECK ADD CONSTRAINT [FK_pf_UserImages_pf_PopForumsUser] FOREIGN KEY([UserID]) REFERENCES [dbo].[pf_PopForumsUser] ([UserID]) ON DELETE CASCADE; -- ******************************************************** pf_UserAvatar CREATE TABLE [dbo].[pf_UserAvatar]( [UserAvatarID] [int] IDENTITY(1,1) NOT NULL PRIMARY KEY NONCLUSTERED, [UserID] [int] NOT NULL, [TimeStamp] [datetime] NOT NULL, [ImageData] varbinary(MAX) NOT NULL ); CREATE CLUSTERED INDEX [IX_pf_UserAvatar_UserID] ON [dbo].[pf_UserAvatar] ( [UserID] ASC ); ALTER TABLE [dbo].[pf_UserAvatar] WITH CHECK ADD CONSTRAINT [FK_pf_UserAvatar_pf_PopForumsUser] FOREIGN KEY([UserID]) REFERENCES [dbo].[pf_PopForumsUser] ([UserID]) ON DELETE CASCADE; -- ******************************************************** pf_ForumPostRestrictions CREATE TABLE [dbo].[pf_ForumPostRestrictions]( [ForumID] [int] NOT NULL, [Role] [nvarchar](256) NOT NULL ); CREATE CLUSTERED INDEX [IX_ForumPostRestrictions_ForumID] ON [dbo].[pf_ForumPostRestrictions] ( [ForumID] ASC ); ALTER TABLE [dbo].[pf_ForumPostRestrictions] WITH CHECK ADD CONSTRAINT [FK_pf_ForumPostRestrictions_ForumID] FOREIGN KEY([ForumID]) REFERENCES [dbo].[pf_Forum] ([ForumID]) ON DELETE CASCADE; ALTER TABLE [dbo].[pf_ForumPostRestrictions] WITH CHECK ADD CONSTRAINT [FK_pf_ForumPostRestrictions_Role] FOREIGN KEY([Role]) REFERENCES [dbo].[pf_Role] ([Role]) ON DELETE CASCADE; -- ******************************************************** pf_ForumViewRestrictions CREATE TABLE [dbo].[pf_ForumViewRestrictions]( [ForumID] [int] NOT NULL, [Role] [nvarchar](256) NOT NULL ); CREATE CLUSTERED INDEX [IX_pf_ForumViewRestrictions_ForumID] ON [dbo].[pf_ForumViewRestrictions] ( [ForumID] ASC ); ALTER TABLE [dbo].[pf_ForumViewRestrictions] WITH CHECK ADD CONSTRAINT [FK_pf_ForumViewRestrictions_ForumID] FOREIGN KEY([ForumID]) REFERENCES [dbo].[pf_Forum] ([ForumID]) ON DELETE CASCADE; ALTER TABLE [dbo].[pf_ForumViewRestrictions] WITH CHECK ADD CONSTRAINT [FK_pf_ForumViewRestrictions_Role] FOREIGN KEY([Role]) REFERENCES [dbo].[pf_Role] ([Role]) ON DELETE CASCADE; -- ******************************************************** pf_TopicSearchWords CREATE TABLE [dbo].[pf_TopicSearchWords]( [SearchWord] [nvarchar](256) NOT NULL, [TopicID] [int] NOT NULL, [Rank] [int] NOT NULL ); CREATE CLUSTERED INDEX [IX_TopicSearchWords_SearchWord_Rank] ON [dbo].[pf_TopicSearchWords] ( [SearchWord] ASC, [Rank] DESC ); CREATE NONCLUSTERED INDEX [IX_TopicSearchWords_TopicID] ON [dbo].[pf_TopicSearchWords] ( [TopicID] ASC ); -- ******************************************************** pf_JunkWords CREATE TABLE [dbo].[pf_JunkWords]( [JunkWord] [nvarchar](256) NOT NULL PRIMARY KEY ); -- ******************************************************** pf_Favorite CREATE TABLE [dbo].[pf_Favorite]( [UserID] [int] NOT NULL, [TopicID] [int] NOT NULL ); ALTER TABLE [dbo].[pf_Favorite] WITH CHECK ADD CONSTRAINT [FK_pf_Favorite_pf_PopForumsUser] FOREIGN KEY([UserID]) REFERENCES [dbo].[pf_PopForumsUser] ([UserID]) ON DELETE CASCADE; ALTER TABLE [dbo].[pf_Favorite] WITH CHECK ADD CONSTRAINT [FK_pf_Favorite_pf_Topic] FOREIGN KEY([TopicID]) REFERENCES [dbo].[pf_Topic] ([TopicID]) ON DELETE CASCADE; CREATE CLUSTERED INDEX [IX_pf_Favorite_UserID] ON [dbo].[pf_Favorite] ( [UserID] ASC ); CREATE NONCLUSTERED INDEX [IX_pf_Favorite_TopicID] ON [dbo].[pf_Favorite] ( [TopicID] ASC ); -- ******************************************************** pf_SubscribeTopic CREATE TABLE [dbo].[pf_SubscribeTopic]( [UserID] [int] NOT NULL, [TopicID] [int] NOT NULL ); ALTER TABLE [dbo].[pf_SubscribeTopic] WITH CHECK ADD CONSTRAINT [FK_pf_SubscribeTopic_pf_PopForumsUser] FOREIGN KEY([UserID]) REFERENCES [dbo].[pf_PopForumsUser] ([UserID]) ON DELETE CASCADE; ALTER TABLE [dbo].[pf_SubscribeTopic] WITH CHECK ADD CONSTRAINT [FK_pf_SubscribeTopic_pf_Topic] FOREIGN KEY([TopicID]) REFERENCES [dbo].[pf_Topic] ([TopicID]) ON DELETE CASCADE; CREATE UNIQUE CLUSTERED INDEX [IX_pf_SubscribeTopic_UserID_TopicID] ON [dbo].[pf_SubscribeTopic] ( [TopicID] ASC, [UserID] ASC ); CREATE INDEX [IX_pf_SubscribeTopic_UserID] ON [dbo].[pf_SubscribeTopic] ( [UserID] ASC ); -- ******************************************************** pf_LastTopicView CREATE TABLE [dbo].[pf_LastTopicView]( [UserID] [int] NOT NULL, [TopicID] [int] NOT NULL, [LastTopicViewDate] [datetime] NOT NULL ); ALTER TABLE [dbo].[pf_LastTopicView] WITH CHECK ADD CONSTRAINT [FK_pf_LastTopicView_TopicID] FOREIGN KEY([TopicID]) REFERENCES [dbo].[pf_Topic] ([TopicID]) ON DELETE CASCADE; ALTER TABLE [dbo].[pf_LastTopicView] CHECK CONSTRAINT [FK_pf_LastTopicView_TopicID]; ALTER TABLE [dbo].[pf_LastTopicView] WITH CHECK ADD CONSTRAINT [FK_pf_LastTopicView_UserID] FOREIGN KEY([UserID]) REFERENCES [dbo].[pf_PopForumsUser] ([UserID]) ON DELETE CASCADE; ALTER TABLE [dbo].[pf_LastTopicView] CHECK CONSTRAINT [FK_pf_LastTopicView_UserID]; CREATE CLUSTERED INDEX [IX_LastTopicVIew_UserID] ON [dbo].[pf_LastTopicView] ( [UserID] ASC ); CREATE NONCLUSTERED INDEX [IX_pf_LastTopicView_TopicID] ON [dbo].[pf_LastTopicView] ( [TopicID] ASC ); -- **************************************************** [pf_PrivateMessage] CREATE TABLE [dbo].[pf_PrivateMessage]( [PMID] [int] IDENTITY(1,1) NOT NULL, [LastPostTime] [datetime] NOT NULL, [Users] [nvarchar](MAX) NOT NULL, CONSTRAINT [PK_pf_PrivateMessage] PRIMARY KEY CLUSTERED ( [PMID] ASC ) ); CREATE TABLE [dbo].[pf_PrivateMessagePost]( [PMPostID] [int] IDENTITY(1,1) NOT NULL, [PMID] [int] NOT NULL, [UserID] [int] NOT NULL, [Name] [nvarchar](256) NOT NULL, [PostTime] [datetime] NOT NULL, [FullText] [nvarchar](MAX) NOT NULL ); CREATE CLUSTERED INDEX [IX_pf_PrivateMessagePost_PMID] ON [dbo].[pf_PrivateMessagePost] ( [PMID] ASC ); ALTER TABLE [dbo].[pf_PrivateMessagePost] WITH CHECK ADD CONSTRAINT [FK_pf_PrivateMessagePost_pf_PrivateMessage] FOREIGN KEY([PMID]) REFERENCES [dbo].[pf_PrivateMessage] ([PMID]) ON DELETE CASCADE; ALTER TABLE [dbo].[pf_PrivateMessagePost] CHECK CONSTRAINT [FK_pf_PrivateMessagePost_pf_PrivateMessage]; CREATE TABLE [dbo].[pf_PrivateMessageUser]( [PMID] [int] NOT NULL, [UserID] [int] NOT NULL, [LastViewDate] [datetime] NOT NULL, [IsArchived] [bit] NOT NULL ); CREATE CLUSTERED INDEX [IX_pf_PrivateMessageUser_PMID] ON [dbo].[pf_PrivateMessageUser] ( [PMID] ASC ); ALTER TABLE [dbo].[pf_PrivateMessageUser] WITH CHECK ADD CONSTRAINT [FK_pf_PrivateMessageUser_pf_PopForumsUser] FOREIGN KEY([UserID]) REFERENCES [dbo].[pf_PopForumsUser] ([UserID]) ON DELETE CASCADE; ALTER TABLE [dbo].[pf_PrivateMessageUser] CHECK CONSTRAINT [FK_pf_PrivateMessageUser_pf_PopForumsUser]; ALTER TABLE [dbo].[pf_PrivateMessageUser] WITH CHECK ADD CONSTRAINT [FK_pf_PrivateMessageUser_pf_PrivateMessage] FOREIGN KEY([PMID]) REFERENCES [dbo].[pf_PrivateMessage] ([PMID]) ON DELETE CASCADE; ALTER TABLE [dbo].[pf_PrivateMessageUser] CHECK CONSTRAINT [FK_pf_PrivateMessageUser_pf_PrivateMessage]; -- ******************************** [pf_PostVote] CREATE TABLE [dbo].[pf_PostVote]( [PostID] [int] NOT NULL, [UserID] [int] NOT NULL ); CREATE CLUSTERED INDEX [IX_pf_PostVote_PostID] ON [dbo].[pf_PostVote] ( [PostID] ASC ); -- ***************************** [pf_Feed] CREATE TABLE [dbo].[pf_Feed]( [UserID] [int] NOT NULL, [Message] [nvarchar](max) NOT NULL, [Points] [int] NOT NULL, [TimeStamp] [datetime] NOT NULL ); CREATE CLUSTERED INDEX [IX_pf_Feed_UserID] ON [dbo].[pf_Feed] ( [UserID] ASC ); -- ***************************** [pf_PointLedger] CREATE TABLE [dbo].[pf_PointLedger]( [UserID] [int] NOT NULL, [EventDefinitionID] [nvarchar](256) NOT NULL, [Points] [int] NOT NULL, [TimeStamp] [datetime] NOT NULL ); CREATE CLUSTERED INDEX [IX_pf_PointLedger_UserID] ON [dbo].[pf_PointLedger] ( [UserID] ASC ); -- ******************************** [pf_EventDefinition] CREATE TABLE [dbo].[pf_EventDefinition]( [EventDefinitionID] [nvarchar](256) NOT NULL PRIMARY KEY CLUSTERED, [Description] [nvarchar](max) NOT NULL, [PointValue] [int] NOT NULL, [IsPublishedToFeed] [bit] NOT NULL ); -- ****************************** [pf_AwardCalculationQueue] CREATE TABLE [dbo].[pf_AwardCalculationQueue]( [Id] [int] IDENTITY(1,1) NOT NULL PRIMARY KEY CLUSTERED, [Payload] [nvarchar](256) NOT NULL ); -- ******************************* [pf_UserAward] CREATE TABLE [dbo].[pf_UserAward]( [UserAwardID] [int] IDENTITY(1,1) NOT NULL PRIMARY KEY NONCLUSTERED, [UserID] [int] NOT NULL, [AwardDefinitionID] [nvarchar](256) NOT NULL, [Title] [nvarchar](256) NOT NULL, [Description] [nvarchar](max) NOT NULL, [TimeStamp] [datetime] NOT NULL ); CREATE CLUSTERED INDEX [IX_pf_UserAward_UserID] ON [dbo].[pf_UserAward] ( [UserID] ASC ); -- ***************************** [pf_AwardDefinition] CREATE TABLE [dbo].[pf_AwardDefinition]( [AwardDefinitionID] [nvarchar](256) NOT NULL PRIMARY KEY CLUSTERED, [Title] [nvarchar](256) NOT NULL, [Description] [nvarchar](256) NOT NULL, [IsSingleTimeAward] [bit] NOT NULL ); -- ******************************* [pf_AwardCondition] CREATE TABLE [dbo].[pf_AwardCondition]( [AwardDefinitionID] [nvarchar](256) NOT NULL, [EventDefinitionID] [nvarchar](256) NOT NULL, [EventCount] [int] NOT NULL ); CREATE CLUSTERED INDEX [IX_AwardCondition_EventDefinitionID] ON [dbo].[pf_AwardCondition] ( [EventDefinitionID] ASC ); ALTER TABLE [dbo].[pf_AwardCondition] WITH CHECK ADD CONSTRAINT [FK_pf_AwardCondition_pf_AwardDefinition] FOREIGN KEY([AwardDefinitionID]) REFERENCES [dbo].[pf_AwardDefinition] ([AwardDefinitionID]) ON DELETE CASCADE; ALTER TABLE [dbo].[pf_AwardCondition] CHECK CONSTRAINT [FK_pf_AwardCondition_pf_AwardDefinition]; -- ******************************* [pf_ExternalUserAssociation] CREATE TABLE [dbo].[pf_ExternalUserAssociation]( [ExternalUserAssociationID] [int] IDENTITY(1,1) NOT NULL PRIMARY KEY NONCLUSTERED, [UserID] [int] NOT NULL, [Issuer] [nvarchar](50) NOT NULL, [ProviderKey] [nvarchar](256) NOT NULL, [Name] [nvarchar](256) NOT NULL ); CREATE NONCLUSTERED INDEX [IX_pf_ExternalUserAssociation_Issuer_ProviderKey] ON [dbo].[pf_ExternalUserAssociation] ( [Issuer] ASC, [ProviderKey] ASC ); CREATE CLUSTERED INDEX [IX_pf_ExternalUserAssociation_UserID] ON [dbo].[pf_ExternalUserAssociation] ( [UserID] ASC ); CREATE TABLE [dbo].[pf_EmailQueue]( [Id] [int] IDENTITY(1,1) NOT NULL, [Payload] [nvarchar](256) NOT NULL ); CREATE CLUSTERED INDEX IX_pf_EmailQueue_Id ON pf_EmailQueue (Id); CREATE TABLE [dbo].[pf_SearchQueue]( [Id] [int] IDENTITY(1,1) NOT NULL, [Payload] [nvarchar](256) NOT NULL ); CREATE CLUSTERED INDEX IX_pf_SearchQueue_ID ON pf_SearchQueue (ID); CREATE TABLE [dbo].[pf_SubNotifyQueue]( [Id] [int] IDENTITY(1,1) NOT NULL, [Payload] [nvarchar](256) NOT NULL ); CREATE CLUSTERED INDEX IX_pf_SubNotifyQueue_ID ON pf_SubNotifyQueue (ID); CREATE TABLE [dbo].[pf_ServiceHeartbeat]( [ServiceName] [nvarchar](256) NOT NULL, [MachineName] [nvarchar](256) NOT NULL, [LastRun] [datetime] NOT NULL, ); CREATE TABLE [dbo].[pf_TopicViewLog]( [ID] [int] IDENTITY(1,1) NOT NULL PRIMARY KEY NONCLUSTERED, [UserID] [int] NULL, [TopicID] [int] NULL, [TimeStamp] [datetime] NOT NULL ); CREATE CLUSTERED INDEX [IX_pf_TopicViewLog] ON [dbo].[pf_TopicViewLog] ( [TimeStamp] ASC ); CREATE TABLE [dbo].[pf_Notifications] ( [UserID] INT NOT NULL, [NotificationType] INT NOT NULL, [ContextID] BIGINT NOT NULL, [TimeStamp] DATETIME NOT NULL, [IsRead] BIT NOT NULL, [Data] NVARCHAR(MAX) NULL ); CREATE CLUSTERED INDEX [IX_pf_Notifications_UserID_TimeStamp] ON [dbo].[pf_Notifications] ( UserID, [TimeStamp] DESC ); CREATE INDEX [IX_pf_Notifications_Context] ON [dbo].[pf_Notifications] ( UserID, NotificationType, ContextID ); CREATE TABLE [dbo].[pf_PostImage] ( [ID] NVARCHAR(50) NOT NULL PRIMARY KEY, [TimeStamp] DATETIME NOT NULL, [TenantID] NVARCHAR(100) NULL, [ContentType] NVARCHAR(50) NOT NULL, [ImageData] VARBINARY(MAX) NOT NULL ); CREATE INDEX [IX_pf_PostImage_TenantID] ON [dbo].[pf_PostImage] ([TenantID]); CREATE TABLE [dbo].[pf_PostImageTemp]( [PostImageTempID] [uniqueidentifier] NOT NULL PRIMARY KEY, [TimeStamp] [datetime] NOT NULL, [TenantID] NVARCHAR(100) NULL ); CREATE NONCLUSTERED INDEX [IX_pf_PostImageTemp_TimeStamp] ON [dbo].[pf_PostImageTemp] ([TimeStamp]); CREATE TABLE [dbo].[pf_Ignore]( [UserID] [int] NOT NULL, [IgnoreUserID] [int] NOT NULL ); ALTER TABLE [dbo].[pf_Ignore] WITH CHECK ADD CONSTRAINT [FK_pf_Ignore_UserID] FOREIGN KEY([UserID]) REFERENCES [dbo].[pf_PopForumsUser] ([UserID]) ON DELETE CASCADE; CREATE CLUSTERED INDEX IX_pf_Ignore_UserID ON pf_Ignore (UserID, IgnoreUserID); INSERT INTO pf_JunkWords (JunkWord) VALUES ('an'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('and'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('any'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('are'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('as'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('at'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('be'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('been'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('but'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('by'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('can'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('did'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('didn''t'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('do'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('does'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('don''t'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('for'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('from'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('gave'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('get'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('go'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('got'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('had'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('has'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('have'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('he'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('her'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('hers'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('here'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('his'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('i''d'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('if'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('in'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('is'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('it'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('its'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('it''s'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('i''ve'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('let''s'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('like'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('lot'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('me'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('my'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('no'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('not'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('of'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('or'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('our'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('out'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('say'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('says'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('she'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('so'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('some'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('such'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('than'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('that'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('that''s'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('the'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('their'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('there'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('they'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('the''ve'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('this'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('those'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('to'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('us'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('very'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('was'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('was''nt'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('way'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('we'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('went'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('were'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('what'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('where'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('which'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('who'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('why'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('with'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('would'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('you'); INSERT INTO pf_JunkWords (JunkWord) VALUES ('your'); ================================================ FILE: src/PopForums.Sql/PopForums13to14.sql ================================================ IF OBJECT_ID('pf_EmailQueue', 'U') IS NULL BEGIN CREATE TABLE [dbo].[pf_EmailQueue]( [Id] [int] IDENTITY(1,1) NOT NULL, [Payload] [nvarchar](256) NOT NULL ) ON [PRIMARY] CREATE CLUSTERED INDEX IX_pf_EmailQueue_Id ON pf_EmailQueue (Id) END IF OBJECT_ID('pf_SearchQueue', 'U') IS NULL BEGIN CREATE TABLE [dbo].[pf_SearchQueue]( [ID] [int] IDENTITY(1,1) NOT NULL, [TopicID] [int] NOT NULL ) CREATE CLUSTERED INDEX IX_pf_SearchQueue_ID ON pf_SearchQueue (ID) END IF OBJECT_ID('pf_ServiceHeartbeat', 'U') IS NULL BEGIN CREATE TABLE [dbo].[pf_ServiceHeartbeat]( [ServiceName] [nvarchar](256) NOT NULL, [MachineName] [nvarchar](256) NOT NULL, [LastRun] [datetime] NOT NULL, CONSTRAINT [PK_pf_ServiceHeartbeat] PRIMARY KEY CLUSTERED ( [ServiceName] ASC, [MachineName] ASC ) ) END IF EXISTS( SELECT TOP 1 1 FROM sys.objects o INNER JOIN sys.columns c ON o.object_id = c.object_id WHERE o.name = 'pf_Profile' AND c.name = 'AIM') ALTER TABLE dbo.pf_Profile DROP COLUMN [AIM] GO ================================================ FILE: src/PopForums.Sql/PopForums14to15.sql ================================================ CREATE CLUSTERED INDEX [IX_pf_Topic_ForumID] ON [dbo].[pf_Topic] ( [ForumID] ASC, [IsPinned] DESC, [LastPostTime] DESC ) WITH (drop_existing = on) IF IndexProperty(Object_Id('pf_Topic'), 'IX_pf_Topic_LastPostTime', 'IndexId') IS NULL CREATE NONCLUSTERED INDEX [IX_pf_Topic_LastPostTime] ON [dbo].[pf_Topic] ( [LastPostTime] DESC ) IF IndexProperty(Object_Id('pf_Favorite'), 'IX_pf_Favorite_TopicID', 'IndexId') IS NULL CREATE NONCLUSTERED INDEX [IX_pf_Favorite_TopicID] ON [dbo].[pf_Favorite] ( [TopicID] ASC ) IF IndexProperty(Object_Id('pf_LastTopicView'), 'IX_pf_LastTopicView_TopicID', 'IndexId') IS NULL CREATE NONCLUSTERED INDEX [IX_pf_LastTopicView_TopicID] ON [dbo].[pf_LastTopicView] ( [TopicID] ASC ) IF EXISTS( SELECT TOP 1 1 FROM sys.objects o INNER JOIN sys.columns c ON o.object_id = c.object_id WHERE o.name = 'pf_Profile' AND c.name = 'ICQ') ALTER TABLE dbo.pf_Profile DROP COLUMN [ICQ] IF EXISTS( SELECT TOP 1 1 FROM sys.objects o INNER JOIN sys.columns c ON o.object_id = c.object_id WHERE o.name = 'pf_Profile' AND c.name = 'YahooMessenger') ALTER TABLE dbo.pf_Profile DROP COLUMN [YahooMessenger] IF NOT EXISTS( SELECT TOP 1 1 FROM sys.objects o INNER JOIN sys.columns c ON o.object_id = c.object_id WHERE o.name = 'pf_Profile' AND c.name = 'Instagram') ALTER TABLE dbo.pf_Profile ADD [Instagram] nvarchar(256) NULL IF IndexProperty(Object_Id('pf_SecurityLog'), 'IX_pf_SecurityLog_IP', 'IndexId') IS NOT NULL DROP INDEX [IX_pf_SecurityLog_IP] ON [dbo].[pf_SecurityLog] IF IndexProperty(Object_Id('pf_SecurityLog'), 'IX_pf_SecurityLog_IP_ActivityDate', 'IndexId') IS NULL CREATE NONCLUSTERED INDEX [IX_pf_SecurityLog_IP_ActivityDate] ON [dbo].[pf_SecurityLog] ( [IP] ASC, [ActivityDate] DESC ) IF IndexProperty(Object_Id('pf_SecurityLog'), 'IX_pf_SecurityLog_UserID_ActivityDate', 'IndexId') IS NULL CREATE NONCLUSTERED INDEX [IX_pf_SecurityLog_UserID_ActivityDate] ON [dbo].[pf_SecurityLog] ( [UserID] ASC, [ActivityDate] DESC ) IF IndexProperty(Object_Id('pf_SecurityLog'), 'IX_pf_SecurityLog_TargetUserID_ActivityDate', 'IndexId') IS NULL CREATE NONCLUSTERED INDEX [IX_pf_SecurityLog_TargetUserID_ActivityDate] ON [dbo].[pf_SecurityLog] ( [TargetUserID] ASC, [ActivityDate] DESC ) IF IndexProperty(Object_Id('pf_Post'), 'IX_pf_Post_IP', 'IndexId') IS NOT NULL DROP INDEX [IX_pf_Post_IP] ON [dbo].[pf_Post] IF IndexProperty(Object_Id('pf_Post'), 'IX_pf_Post_IP_PostTime', 'IndexId') IS NULL CREATE NONCLUSTERED INDEX [IX_pf_Post_IP_PostTime] ON [dbo].[pf_Post] ( [IP] ASC, [PostTime] DESC ) DROP TABLE [dbo].[pf_AwardCalculationQueue] CREATE TABLE [dbo].[pf_AwardCalculationQueue]( [Id] [int] IDENTITY(1,1) NOT NULL PRIMARY KEY CLUSTERED, [Payload] [nvarchar](256) NOT NULL ) DROP TABLE [dbo].[pf_SearchQueue] CREATE TABLE [dbo].[pf_SearchQueue]( [Id] [int] IDENTITY(1,1) NOT NULL, [Payload] [nvarchar](256) NOT NULL ) IF IndexProperty(Object_Id('pf_Topic'), 'pf_Topic_IsIndexed_IsDeleted', 'IndexId') IS NOT NULL DROP INDEX [pf_Topic_IsIndexed_IsDeleted] ON [dbo].[pf_Topic] IF EXISTS( SELECT TOP 1 1 FROM sys.objects o INNER JOIN sys.columns c ON o.object_id = c.object_id WHERE o.name = 'pf_Topic' AND c.name = 'IsIndexed') ALTER TABLE dbo.pf_Topic DROP COLUMN [IsIndexed] DROP TABLE [dbo].[pf_ServiceHeartbeat] CREATE TABLE [dbo].[pf_ServiceHeartbeat]( [ServiceName] [nvarchar](256) NOT NULL, [MachineName] [nvarchar](256) NOT NULL, [LastRun] [datetime] NOT NULL, ) IF (NOT EXISTS (SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'dbo' AND TABLE_NAME = 'pf_TopicViewLog')) BEGIN CREATE TABLE [dbo].[pf_TopicViewLog]( [ID] [int] IDENTITY(1,1) NOT NULL PRIMARY KEY NONCLUSTERED, [UserID] [int] NULL, [TopicID] [int] NULL, [TimeStamp] [datetime] NOT NULL ) END IF IndexProperty(Object_Id('pf_TopicViewLog'), 'IX_pf_TopicViewLog_TimeStamp', 'IndexId') IS NULL CREATE CLUSTERED INDEX [IX_pf_TopicViewLog_TimeStamp] ON [dbo].[pf_TopicViewLog] ( [TimeStamp] ASC ) ================================================ FILE: src/PopForums.Sql/PopForums15to16.sql ================================================ DROP INDEX [IX_PopForumsUser_UserName] ON [dbo].[pf_PopForumsUser] CREATE UNIQUE NONCLUSTERED INDEX [IX_PopForumsUser_UserName] ON [dbo].[pf_PopForumsUser]([Name]) DROP INDEX [IX_PopForumsUser_Email] ON [dbo].[pf_PopForumsUser] CREATE UNIQUE NONCLUSTERED INDEX [IX_PopForumsUser_Email] ON [dbo].[pf_PopForumsUser]([Email]) DELETE FROM pf_Setting WHERE [Setting] = 'TwitterConsumerKey' DELETE FROM pf_Setting WHERE [Setting] = 'TwitterConsumerSecret' DELETE FROM pf_Setting WHERE [Setting] = 'UseTwitterLogin' IF (NOT EXISTS (SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'dbo' AND TABLE_NAME = 'pf_UserActivity')) BEGIN CREATE TABLE [dbo].[pf_UserActivity]( [UserID] [int] NOT NULL PRIMARY KEY CLUSTERED, [LastActivityDate] [datetime] NOT NULL, [LastLoginDate] [datetime] NOT NULL ) ALTER TABLE [dbo].[pf_UserActivity] WITH CHECK ADD CONSTRAINT [FK_pf_UserActivity_pf_PopForumsUser] FOREIGN KEY([UserID]) REFERENCES [dbo].[pf_PopForumsUser] ([UserID]) ON DELETE CASCADE ALTER TABLE [dbo].[pf_UserActivity] CHECK CONSTRAINT [FK_pf_UserActivity_pf_PopForumsUser] INSERT INTO pf_UserActivity SELECT UserID, LastActivityDate, LastLoginDate FROM pf_PopForumsUser END IF EXISTS( SELECT TOP 1 1 FROM sys.objects o INNER JOIN sys.columns c ON o.object_id = c.object_id WHERE o.name = 'pf_PopForumsUser' AND c.name = 'LastActivityDate') ALTER TABLE dbo.pf_PopForumsUser DROP COLUMN [LastActivityDate] IF EXISTS( SELECT TOP 1 1 FROM sys.objects o INNER JOIN sys.columns c ON o.object_id = c.object_id WHERE o.name = 'pf_PopForumsUser' AND c.name = 'LastLoginDate') ALTER TABLE dbo.pf_PopForumsUser DROP COLUMN [LastLoginDate] ================================================ FILE: src/PopForums.Sql/PopForums16to21.sql ================================================ -- For recent users view in admin IF INDEXPROPERTY(Object_Id('pf_SecurityLog'), 'IX_pf_SecurityLog_TargetUserID_SecurityLogType', 'IndexID') IS NULL BEGIN CREATE NONCLUSTERED INDEX IX_pf_SecurityLog_TargetUserID_SecurityLogType ON pf_SecurityLog ( TargetUserID DESC, SecurityLogType ); END DROP INDEX IF EXISTS [IX_Friend_ToUserID] ON [pf_Friend]; DROP INDEX IF EXISTS [IX_Friend_FromUserID] ON [pf_Friend]; IF OBJECT_ID('pf_Friend', 'U') IS NOT NULL BEGIN DROP TABLE pf_Friend; END IF COL_LENGTH('dbo.pf_Profile', 'TimeZone') IS NOT NULL BEGIN ALTER TABLE pf_Profile DROP COLUMN TimeZone; END IF COL_LENGTH('dbo.pf_Profile', 'IsDaylightSaving') IS NOT NULL BEGIN ALTER TABLE pf_Profile DROP COLUMN IsDaylightSaving; END DELETE FROM pf_Setting WHERE Setting = 'ServerDaylightSaving' OR Setting = 'ServerTimeZone'; IF OBJECT_ID('pf_Notifications', 'U') IS NULL BEGIN CREATE TABLE [dbo].[pf_Notifications] ( [UserID] INT NOT NULL, [NotificationType] INT NOT NULL, [ContextID] BIGINT NOT NULL, [TimeStamp] DATETIME NOT NULL, [IsRead] BIT NOT NULL, [Data] NVARCHAR(MAX) NULL ); END IF INDEXPROPERTY(Object_Id('pf_Notifications'), 'IX_pf_Notifications_UserID_TimeStamp', 'IndexID') IS NULL BEGIN CREATE CLUSTERED INDEX [IX_pf_Notifications_UserID_TimeStamp] ON [dbo].[pf_Notifications] ( UserID, [TimeStamp] DESC ); END IF INDEXPROPERTY(Object_Id('pf_Notifications'), 'IX_pf_Notifications_Context', 'IndexID') IS NULL BEGIN CREATE INDEX [IX_pf_Notifications_Context] ON [dbo].[pf_Notifications] ( UserID, NotificationType, ContextID ); END IF COL_LENGTH('dbo.pf_Profile', 'IsAutoFollowOnReply') IS NULL BEGIN ALTER TABLE pf_Profile ADD [IsAutoFollowOnReply] [bit] NOT NULL DEFAULT(1); END IF COL_LENGTH('dbo.pf_SubscribeTopic', 'IsViewed') IS NOT NULL BEGIN ALTER TABLE pf_SubscribeTopic DROP COLUMN IsViewed; END IF INDEXPROPERTY(Object_Id('pf_SubscribeTopic'), 'IX_pf_SubscribeTopic_TopicID_UserID', 'IndexID') IS NOT NULL BEGIN DROP INDEX [IX_pf_SubscribeTopic_TopicID_UserID] ON [dbo].[pf_SubscribeTopic]; END IF INDEXPROPERTY(Object_Id('pf_SubscribeTopic'), 'IX_pf_SubscribeTopic_UserID_TopicID', 'IndexID') IS NULL BEGIN CREATE UNIQUE CLUSTERED INDEX [IX_pf_SubscribeTopic_UserID_TopicID] ON [dbo].[pf_SubscribeTopic] ( [TopicID] ASC, [UserID] ASC ); END IF INDEXPROPERTY(Object_Id('pf_SubscribeTopic'), 'IX_pf_SubscribeTopic_UserID', 'IndexID') IS NULL BEGIN CREATE INDEX [IX_pf_SubscribeTopic_UserID] ON [dbo].[pf_SubscribeTopic] ( [UserID] ASC ); END IF OBJECT_ID('pf_PostImage', 'U') IS NULL BEGIN CREATE TABLE [dbo].[pf_PostImage] ( [ID] NVARCHAR(50) NOT NULL PRIMARY KEY, [TimeStamp] DATETIME NOT NULL, [TenantID] NVARCHAR(100) NULL, [ContentType] NVARCHAR(50) NOT NULL, [ImageData] VARBINARY(MAX) NOT NULL ); END IF INDEXPROPERTY(Object_Id('pf_PostImage'), 'IX_pf_PostImage_TenantID', 'IndexID') IS NULL BEGIN CREATE INDEX [IX_pf_PostImage_TenantID] ON [dbo].[pf_PostImage] ([TenantID]); END IF OBJECT_ID('pf_PostImageTemp', 'U') IS NULL BEGIN CREATE TABLE [dbo].[pf_PostImageTemp]( [PostImageTempID] [uniqueidentifier] NOT NULL PRIMARY KEY, [TimeStamp] [datetime] NOT NULL, [TenantID] NVARCHAR(100) NULL ); END IF INDEXPROPERTY(Object_Id('pf_PostImageTemp'), 'IX_pf_PostImageTemp_TimeStamp', 'IndexID') IS NULL BEGIN CREATE NONCLUSTERED INDEX [IX_pf_PostImageTemp_TimeStamp] ON [dbo].[pf_PostImageTemp] ([TimeStamp]); END IF COL_LENGTH('dbo.pf_PrivateMessage', 'UserNames') IS NOT NULL BEGIN ALTER TABLE pf_PrivateMessage DROP COLUMN UserNames; END IF COL_LENGTH('dbo.pf_PrivateMessage', 'Subject') IS NOT NULL BEGIN ALTER TABLE pf_PrivateMessage DROP COLUMN [Subject]; END IF COL_LENGTH('dbo.pf_PrivateMessage', 'Users') IS NULL BEGIN ALTER TABLE pf_PrivateMessage ADD [Users] [nvarchar](MAX) NOT NULL DEFAULT('{}'); END IF OBJECT_ID('pf_SubNotifyQueue', 'U') IS NULL BEGIN CREATE TABLE [dbo].[pf_SubNotifyQueue]( [Id] [int] IDENTITY(1,1) NOT NULL, [Payload] [nvarchar](256) NOT NULL ); END IF INDEXPROPERTY(Object_Id('pf_SubNotifyQueue'), 'IX_pf_SubNotifyQueue_ID', 'IndexID') IS NULL BEGIN CREATE CLUSTERED INDEX IX_pf_SubNotifyQueue_ID ON pf_SubNotifyQueue (ID); END IF COL_LENGTH('dbo.pf_QueuedEmailMessage', 'FromEmail') IS NOT NULL BEGIN ALTER TABLE pf_QueuedEmailMessage DROP COLUMN FromEmail; END IF COL_LENGTH('dbo.pf_PopForumsUser', 'TokenExpiration') IS NULL BEGIN ALTER TABLE pf_PopForumsUser ADD [TokenExpiration] [datetime] NULL; END IF COL_LENGTH('dbo.pf_UserActivity', 'RefreshToken') IS NULL BEGIN ALTER TABLE pf_UserActivity ADD [RefreshToken] [nvarchar](MAX) NULL; END IF COL_LENGTH('dbo.pf_Profile', 'Twitter') IS NOT NULL BEGIN ALTER TABLE pf_Profile DROP COLUMN [Twitter]; END ================================================ FILE: src/PopForums.Sql/PopForums19to20.sql ================================================ IF COL_LENGTH('dbo.pf_PopForumsUser', 'TokenExpiration') IS NULL BEGIN ALTER TABLE pf_PopForumsUser ADD [TokenExpiration] [datetime] NULL; END IF COL_LENGTH('dbo.pf_UserActivity', 'RefreshToken') IS NULL BEGIN ALTER TABLE pf_UserActivity ADD [RefreshToken] [nvarchar](MAX) NULL; END ================================================ FILE: src/PopForums.Sql/PopForums20to21.sql ================================================ IF COL_LENGTH('dbo.pf_Profile', 'Twitter') IS NOT NULL BEGIN ALTER TABLE pf_Profile DROP COLUMN [Twitter]; END ================================================ FILE: src/PopForums.Sql/PopForums21to22.sql ================================================ IF OBJECT_ID('pf_Ignore', 'U') IS NULL BEGIN CREATE TABLE [dbo].[pf_Ignore]( [UserID] [int] NOT NULL, [IgnoreUserID] [int] NOT NULL ); ALTER TABLE [dbo].[pf_Ignore] WITH CHECK ADD CONSTRAINT [FK_pf_Ignore_UserID] FOREIGN KEY([UserID]) REFERENCES [dbo].[pf_PopForumsUser] ([UserID]) ON DELETE CASCADE; END IF INDEXPROPERTY(Object_Id('pf_Ignore'), 'IX_pf_Ignore_UserID', 'IndexID') IS NULL BEGIN CREATE CLUSTERED INDEX IX_pf_Ignore_UserID ON pf_Ignore (UserID, IgnoreUserID); END ================================================ FILE: src/PopForums.Sql/Repositories/AwardCalculationQueueRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class AwardCalculationQueueRepository : IAwardCalculationQueueRepository { public AwardCalculationQueueRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } private readonly ISqlObjectFactory _sqlObjectFactory; public async Task Enqueue(AwardCalculationPayload payload) { var serializedPayload = JsonSerializer.Serialize(payload); await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_AwardCalculationQueue (Payload) VALUES (@Payload)", new { Payload = serializedPayload })); } public async Task> Dequeue() { Task serializedPayload = null; var sql = @"WITH cte AS ( SELECT TOP(1) Payload FROM pf_AwardCalculationQueue WITH (ROWLOCK, READPAST) ORDER BY Id) DELETE FROM cte OUTPUT DELETED.Payload;"; await _sqlObjectFactory.GetConnection().UsingAsync(connection => serializedPayload = connection.QuerySingleOrDefaultAsync(sql)); if (string.IsNullOrEmpty(serializedPayload.Result)) return new KeyValuePair(); var payload = JsonSerializer.Deserialize(serializedPayload.Result); return new KeyValuePair(payload.EventDefinitionID, payload.UserID); } } ================================================ FILE: src/PopForums.Sql/Repositories/AwardConditionRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class AwardConditionRepository : IAwardConditionRepository { public AwardConditionRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } private readonly ISqlObjectFactory _sqlObjectFactory; public async Task> GetConditions(string awardDefinitionID) { Task> result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.QueryAsync("SELECT AwardDefinitionID, EventDefinitionID, EventCount FROM pf_AwardCondition WHERE AwardDefinitionID = @AwardDefinitionID", new { AwardDefinitionID = awardDefinitionID })); var list = result.Result.ToList(); return list; } public async Task DeleteConditions(string awardDefinitionID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_AwardCondition WHERE AwardDefinitionID = @AwardDefinitionID", new { AwardDefinitionID = awardDefinitionID })); } public async Task DeleteCondition(string awardDefinitionID, string eventDefinitionID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_AwardCondition WHERE AwardDefinitionID = @AwardDefinitionID AND EventDefinitionID = @EventDefinitionID", new { AwardDefinitionID = awardDefinitionID, EventDefinitionID = eventDefinitionID })); } public async Task DeleteConditionsByEventDefinitionID(string eventDefinitionID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_AwardCondition WHERE EventDefinitionID = @EventDefinitionID", new { EventDefinitionID = eventDefinitionID })); } public async Task SaveConditions(List conditions) { foreach (var condition in conditions) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_AwardCondition (AwardDefinitionID, EventDefinitionID, EventCount) VALUES (@AwardDefinitionID, @EventDefinitionID, @EventCount)", new { condition.AwardDefinitionID, condition.EventDefinitionID, condition.EventCount })); } } } ================================================ FILE: src/PopForums.Sql/Repositories/AwardDefinitionRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class AwardDefinitionRepository : IAwardDefinitionRepository { public AwardDefinitionRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } private readonly ISqlObjectFactory _sqlObjectFactory; public async Task Get(string awardDefinitionID) { Task awardDefinition = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => awardDefinition = connection.QuerySingleOrDefaultAsync("SELECT AwardDefinitionID, Title, Description, IsSingleTimeAward FROM pf_AwardDefinition WHERE AwardDefinitionID = @AwardDefinitionID", new { AwardDefinitionID = awardDefinitionID })); return await awardDefinition; } public async Task> GetAll() { Task> result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.QueryAsync("SELECT AwardDefinitionID, Title, Description, IsSingleTimeAward FROM pf_AwardDefinition ORDER BY AwardDefinitionID")); var list = result.Result.ToList(); return list; } public async Task> GetByEventDefinitionID(string eventDefinitionID) { Task> result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.QueryAsync("SELECT D.AwardDefinitionID, D.Title, D.Description, D.IsSingleTimeAward FROM pf_AwardDefinition D JOIN pf_AwardCondition C ON D.AwardDefinitionID = C.AwardDefinitionID WHERE C.EventDefinitionID = @EventDefinitionID", new { EventDefinitionID = eventDefinitionID })); var list = result.Result.ToList(); return list; } public async Task Create(string awardDefinitionID, string title, string description, bool isSingleTimeAward) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_AwardDefinition (AwardDefinitionID, Title, Description, IsSingleTimeAward) VALUES (@AwardDefinitionID, @Title, @Description, @IsSingleTimeAward)", new { AwardDefinitionID = awardDefinitionID, Title = title, Description = description.NullToEmpty(), IsSingleTimeAward = isSingleTimeAward })); } public async Task Delete(string awardDefinitionID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_AwardDefinition WHERE AwardDefinitionID = @AwardDefinitionID", new { AwardDefinitionID = awardDefinitionID })); } } ================================================ FILE: src/PopForums.Sql/Repositories/BanRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class BanRepository : IBanRepository { public BanRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } private readonly ISqlObjectFactory _sqlObjectFactory; public async Task BanIP(string ip) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_IPBan (IPBan) VALUES (@IPBan)", new { IPBan = ip })); } public async Task RemoveIPBan(string ip) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_IPBan WHERE IPBan = @IPBan", new { IPBan = ip })); } public async Task> GetIPBans() { Task> result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.QueryAsync("SELECT IPBan FROM pf_IPBan ORDER BY IPBan")); var list = result.Result.ToList(); return list; } public async Task IPIsBanned(string ip) { Task> result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.QueryAsync("SELECT * FROM pf_IPBan WHERE CHARINDEX(pf_IPBan.IPBan, @IPBan) > 0", new { IPBan = ip })); var isBanned = result.Result.Any(); return isBanned; } public async Task BanEmail(string email) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_EmailBan (EmailBan) VALUES (@EmailBan)", new { EmailBan = email })); } public async Task RemoveEmailBan(string email) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_EmailBan WHERE EmailBan = @EmailBan", new { EmailBan = email })); } public async Task> GetEmailBans() { Task> result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.QueryAsync("SELECT EmailBan FROM pf_EmailBan ORDER BY EmailBan")); var list = result.Result.ToList(); return list; } public async Task EmailIsBanned(string email) { Task> result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.QueryAsync("SELECT * FROM pf_EmailBan WHERE EmailBan = @EmailBan", new { EmailBan = email })); var isBanned = result.Result.Any(); return isBanned; } } ================================================ FILE: src/PopForums.Sql/Repositories/CategoryRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class CategoryRepository : ICategoryRepository { public CategoryRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } private readonly ISqlObjectFactory _sqlObjectFactory; public async Task Get(int categoryID) { Task category = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => category = connection.QuerySingleOrDefaultAsync("SELECT CategoryID, Title, SortOrder FROM pf_Category WHERE CategoryID = @CategoryID", new { CategoryID = categoryID })); return await category; } public async Task> GetAll() { Task> result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.QueryAsync("SELECT CategoryID, Title, SortOrder FROM pf_Category ORDER BY SortOrder")); var list = result.Result.ToList(); return list; } public async Task Create(string newTitle, int sortOrder) { Task categoryID = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => categoryID = connection.QuerySingleAsync("INSERT INTO pf_Category (Title, SortOrder) VALUES (@Title, @SortOrder);SELECT CAST(SCOPE_IDENTITY() as int)", new { Title = newTitle, SortOrder = sortOrder })); var category = new Category { CategoryID = categoryID.Result, Title = newTitle, SortOrder = sortOrder }; return category; } public async Task Delete(int categoryID) { Task result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.ExecuteAsync("DELETE FROM pf_Category WHERE CategoryID = @CategoryID", new { CategoryID = categoryID })); if (result.Result != 1) throw new Exception($"Can't delete category with ID {categoryID} because it does not exist."); } public async Task Update(Category category) { Task result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.ExecuteAsync("UPDATE pf_Category SET Title = @Title, SortOrder = @SortOrder WHERE CategoryID = @CategoryID", new { category.Title, category.SortOrder, category.CategoryID })); if (result.Result != 1) throw new Exception($"Can't update category with ID {category.CategoryID} because it does not exist."); } } ================================================ FILE: src/PopForums.Sql/Repositories/EmailQueueRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class EmailQueueRepository : IEmailQueueRepository { private readonly ISqlObjectFactory _sqlObjectFactory; public EmailQueueRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } public async Task Enqueue(EmailQueuePayload payload) { var serializedPayload = JsonSerializer.Serialize(payload); await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_EmailQueue (Payload) VALUES (@Payload)", new { Payload = serializedPayload })); } public async Task Dequeue() { Task serializedPayload = null; var sql = @"WITH cte AS ( SELECT TOP(1) Payload FROM pf_EmailQueue WITH (ROWLOCK, READPAST) ORDER BY Id) DELETE FROM cte OUTPUT DELETED.Payload;"; await _sqlObjectFactory.GetConnection().UsingAsync(connection => serializedPayload = connection.QuerySingleOrDefaultAsync(sql)); if (string.IsNullOrEmpty(serializedPayload.Result)) return null; var payload = JsonSerializer.Deserialize(serializedPayload.Result); return payload; } } ================================================ FILE: src/PopForums.Sql/Repositories/ErrorLogRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class ErrorLogRepository : IErrorLogRepository { public ErrorLogRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } private readonly ISqlObjectFactory _sqlObjectFactory; public async Task Create(DateTime timeStamp, string message, string stackTrace, string data, ErrorSeverity severity) { Task errorID = null; try { await _sqlObjectFactory.GetConnection().UsingAsync(connection => errorID = connection.QuerySingleAsync("INSERT INTO pf_ErrorLog (TimeStamp, Message, StackTrace, Data, Severity) VALUES (@TimeStamp, @Message, @StackTrace, @Data, @Severity);SELECT CAST(SCOPE_IDENTITY() as int)", new {TimeStamp = timeStamp, Message = message, StackTrace = stackTrace, Data = data, Severity = severity})); } catch (Exception exc) { // gross, but necessary to prevent a loop trying to record database errors to an unavailable database Console.WriteLine($"Can't log to database because: {exc.Message}\r\nOriginal error: {message}\r\n{stackTrace}"); } var errorLog = new ErrorLogEntry { ErrorID = errorID.Result, TimeStamp = timeStamp, Message = message, StackTrace = stackTrace, Data = data, Severity = severity }; return errorLog; } public async Task GetErrorCount() { Task count = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => count = connection.ExecuteScalarAsync("SELECT COUNT(*) FROM pf_ErrorLog")); return await count; } public async Task> GetErrors(int startRow, int pageSize) { const string sql = @" DECLARE @Counter int SET @Counter = (@StartRow + @PageSize - 1) SET ROWCOUNT @Counter; WITH Entries AS ( SELECT ROW_NUMBER() OVER (ORDER BY TimeStamp DESC) AS Row, pf_ErrorLog.ErrorID, pf_ErrorLog.TimeStamp, pf_ErrorLog.Message, pf_ErrorLog.StackTrace, pf_ErrorLog.Data, pf_ErrorLog.Severity FROM pf_ErrorLog) SELECT ErrorID, TimeStamp, Message, StackTrace, Data, Severity FROM Entries WHERE Row between @StartRow and @StartRow + @PageSize - 1 SET ROWCOUNT 0"; Task> result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.QueryAsync(sql, new { StartRow = startRow, PageSize = pageSize })); var logEntries = result.Result.ToList(); return logEntries; } public async Task DeleteError(int errorID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_ErrorLog WHERE ErrorID = @ErrorID", new { ErrorID = errorID })); } public async Task DeleteAllErrors() { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("TRUNCATE TABLE pf_ErrorLog")); } } ================================================ FILE: src/PopForums.Sql/Repositories/EventDefinitionRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class EventDefinitionRepository : IEventDefinitionRepository { public EventDefinitionRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } private readonly ISqlObjectFactory _sqlObjectFactory; public async Task Get(string eventDefinitionID) { Task eventDef = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => eventDef = connection.QuerySingleAsync("SELECT EventDefinitionID, Description, PointValue, IsPublishedToFeed FROM pf_EventDefinition WHERE EventDefinitionID = @EventDefinitionID", new { EventDefinitionID = eventDefinitionID })); return await eventDef; } public async Task> GetAll() { Task> list = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => list = connection.QueryAsync("SELECT EventDefinitionID, Description, PointValue, IsPublishedToFeed FROM pf_EventDefinition ORDER BY EventDefinitionID")); return list.Result.ToList(); } public async Task Create(EventDefinition eventDefinition) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_EventDefinition (EventDefinitionID, Description, PointValue, IsPublishedToFeed) VALUES (@EventDefinitionID, @Description, @PointValue, @IsPublishedToFeed)", new { eventDefinition.EventDefinitionID, Description = eventDefinition.Description.NullToEmpty(), eventDefinition.PointValue, eventDefinition.IsPublishedToFeed })); } public void Delete(string eventDefinitionID) { _sqlObjectFactory.GetConnection().Using(connection => connection.Execute("DELETE FROM pf_EventDefinition WHERE EventDefinitionID = @EventDefinitionID", new { EventDefinitionID = eventDefinitionID })); } } ================================================ FILE: src/PopForums.Sql/Repositories/ExternalUserAssociationRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class ExternalUserAssociationRepository : IExternalUserAssociationRepository { public ExternalUserAssociationRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } private readonly ISqlObjectFactory _sqlObjectFactory; public async Task Get(string issuer, string providerKey) { Task externalUserAssociation = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => externalUserAssociation = connection.QuerySingleOrDefaultAsync("SELECT ExternalUserAssociationID, UserID, Issuer, ProviderKey, Name FROM pf_ExternalUserAssociation WHERE Issuer = @Issuer AND ProviderKey = @ProviderKey", new { Issuer = issuer, ProviderKey = providerKey })); return await externalUserAssociation; } public async Task Get(int externalUserAssociationID) { Task externalUserAssociation = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => externalUserAssociation = connection.QuerySingleOrDefaultAsync("SELECT ExternalUserAssociationID, UserID, Issuer, ProviderKey, Name FROM pf_ExternalUserAssociation WHERE ExternalUserAssociationID = @ExternalUserAssociationID", new { ExternalUserAssociationID = externalUserAssociationID })); return await externalUserAssociation; } public async Task> GetByUser(int userID) { Task> userAssociations = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => userAssociations = connection.QueryAsync("SELECT ExternalUserAssociationID, UserID, Issuer, ProviderKey, Name FROM pf_ExternalUserAssociation WHERE UserID = @UserID", new { UserID = userID })); return userAssociations.Result.ToList(); } public async Task Save(int userID, string issuer, string providerKey, string name) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_ExternalUserAssociation (UserID, Issuer, ProviderKey, Name) VALUES (@UserID, @Issuer, @ProviderKey, @Name)", new { UserID = userID, Issuer = issuer, ProviderKey = providerKey, Name = name })); } public async Task Delete(int externalUserAssociationID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_ExternalUserAssociation WHERE ExternalUserAssociationID = @ExternalUserAssociationID", new { ExternalUserAssociationID = externalUserAssociationID })); } } ================================================ FILE: src/PopForums.Sql/Repositories/FavoriteTopicsRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class FavoriteTopicsRepository : IFavoriteTopicsRepository { public FavoriteTopicsRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } private readonly ISqlObjectFactory _sqlObjectFactory; public async Task> GetFavoriteTopics(int userID, int startRow, int pageSize) { Task> result = null; const string sql = @" DECLARE @Counter int SET @Counter = (@StartRow + @PageSize - 1) SET ROWCOUNT @Counter; WITH Entries AS ( SELECT ROW_NUMBER() OVER (ORDER BY IsPinned DESC, LastPostTime DESC) AS Row, pf_Topic.TopicID, pf_Topic.ForumID, pf_Topic.Title, pf_Topic.ReplyCount, pf_Topic.ViewCount, pf_Topic.StartedByUserID, pf_Topic.StartedByName, pf_Topic.LastPostUserID, pf_Topic.LastPostName, pf_Topic.LastPostTime, pf_Topic.IsClosed, pf_Topic.IsPinned, pf_Topic.IsDeleted, pf_Topic.UrlName, pf_Topic.AnswerPostID FROM pf_Topic JOIN pf_Favorite F ON pf_Topic.TopicID = F.TopicID WHERE F.UserID = @UserID AND pf_Topic.IsDeleted = 0) SELECT TopicID, ForumID, Title, ReplyCount, ViewCount, StartedByUserID, StartedByName, LastPostUserID, LastPostName, LastPostTime, IsClosed, IsPinned, IsDeleted, UrlName, AnswerPostID FROM Entries WHERE Row between @StartRow and @StartRow + @PageSize - 1 SET ROWCOUNT 0"; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.QueryAsync(sql, new { UserID = userID, StartRow = startRow, PageSize = pageSize })); return result.Result.ToList(); } public async Task GetFavoriteTopicCount(int userID) { Task count = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => count = connection.ExecuteScalarAsync("SELECT COUNT(*) FROM pf_Favorite F JOIN pf_Topic T ON F.TopicID = T.TopicID WHERE F.UserID = @UserID AND T.IsDeleted = 0", new { UserID = userID })); return await count; } public async Task IsTopicFavorite(int userID, int topicID) { Task> result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.QueryAsync("SELECT * FROM pf_Favorite WHERE UserID = @UserID AND TopicID = @TopicID", new { UserID = userID, TopicID = topicID })); return result.Result.Any(); } public async Task AddFavoriteTopic(int userID, int topicID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_Favorite (UserID, TopicID) VALUES (@UserID, @TopicID)", new { UserID = userID, TopicID = topicID })); } public async Task RemoveFavoriteTopic(int userID, int topicID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_Favorite WHERE UserID = @UserID AND TopicID = @TopicID", new { UserID = userID, TopicID = topicID })); } } ================================================ FILE: src/PopForums.Sql/Repositories/FeedRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class FeedRepository : IFeedRepository { public FeedRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } private readonly ISqlObjectFactory _sqlObjectFactory; public async Task> GetFeed(int userID, int itemCount) { Task> result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.QueryAsync($"SELECT TOP {itemCount} UserID, Message, Points, TimeStamp FROM pf_Feed WHERE UserID = @UserID ORDER BY TimeStamp DESC", new { UserID = userID} )); return result.Result.ToList(); } public async Task> GetFeed(int itemCount) { Task> result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.QueryAsync($"SELECT TOP {itemCount} UserID, Message, Points, TimeStamp FROM pf_Feed ORDER BY TimeStamp DESC")); return result.Result.ToList(); } public async Task PublishEvent(int userID, string message, int points, DateTime timeStamp) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_Feed (UserID, Message, Points, TimeStamp) VALUES (@UserID, @Message, @Points, @TimeStamp)", new { UserID = userID, Message = message, Points = points, TimeStamp = timeStamp })); } public async Task GetOldestTime(int userID, int takeCount) { var feed = await GetFeed(userID, takeCount); if (feed.Count == 0) return new DateTime(1990, 1, 1); var last = feed.Last(); return last.TimeStamp; } public async Task DeleteOlderThan(int userID, DateTime timeCutOff) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_Feed WHERE UserID = @UserID AND TimeStamp < @TimeStamp", new { UserID = userID, TimeStamp = timeCutOff })); } } ================================================ FILE: src/PopForums.Sql/Repositories/ForumRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class ForumRepository : IForumRepository { public ForumRepository(ICacheHelper cacheHelper, ISqlObjectFactory sqlObjectFactory) { _cacheHelper = cacheHelper; _sqlObjectFactory = sqlObjectFactory; } private readonly ICacheHelper _cacheHelper; private readonly ISqlObjectFactory _sqlObjectFactory; private const string ForumFields = "ForumID, CategoryID, Title, Description, IsVisible, IsArchived, SortOrder, TopicCount, PostCount, LastPostTime, LastPostName, UrlName, ForumAdapterName, IsQAForum"; public class CacheKeys { public const string ForumPostRoleRestrictions = "PopForums.Forum.ForumPostRoleRestrictions"; public const string ForumViewRoleRestrictions = "PopForums.Forum.ForumViewRoleRestrictions"; public const string ForumUrlNames = "PopForums.Forum.UrlNames"; public const string ForumTitles = "PopForums.Forum.Titles"; public const string AggregateTopicCount = "PopForums.Forum.AggregateTopicCount"; public const string AggregatePostCount = "PopForums.Forum.AggreatePostCount"; } public async Task Get(int forumID) { Task forum = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => forum = connection.QuerySingleOrDefaultAsync("SELECT " + ForumFields + " FROM pf_Forum WHERE ForumID = @ForumID", new { ForumID = forumID })); return await forum; } public async Task Get(string urlName) { Task forum = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => forum = connection.QuerySingleOrDefaultAsync("SELECT " + ForumFields + " FROM pf_Forum WHERE UrlName = @UrlName", new { UrlName = urlName })); return await forum; } public async Task Create(int? categoryID, string title, string description, bool isVisible, bool isArchived, int sortOrder, string urlName, string forumAdapterName, bool isQAForum) { if (categoryID == 0) categoryID = null; Task forumID = null; var lastPostTime = new DateTime(2000, 1, 1); await _sqlObjectFactory.GetConnection().UsingAsync(connection => forumID = connection.QuerySingleAsync("INSERT INTO pf_Forum (CategoryID, Title, Description, IsVisible, IsArchived, SortOrder, TopicCount, PostCount, LastPostTime, LastPostName, UrlName, ForumAdapterName, IsQAForum) VALUES (@CategoryID, @Title, @Description, @IsVisible, @IsArchived, @SortOrder, 0, 0, @LastPostTime, '', @UrlName, @ForumAdapterName, @IsQAForum);SELECT CAST(SCOPE_IDENTITY() as int)", new { CategoryID = categoryID, Title = title, Description = description.NullToEmpty(), IsVisible = isVisible, IsArchived = isArchived, SortOrder = sortOrder, LastPostTime = lastPostTime, UrlName = urlName, ForumAdapterName = forumAdapterName, IsQAForum = isQAForum })); var forum = new Forum { ForumID = forumID.Result, CategoryID = categoryID, Title = title, Description = description, IsVisible = isVisible, IsArchived = isArchived, SortOrder = sortOrder, TopicCount = 0, PostCount = 0, LastPostTime = lastPostTime, LastPostName = String.Empty, UrlName = urlName, ForumAdapterName = forumAdapterName, IsQAForum = isQAForum }; _cacheHelper.RemoveCacheObject(CacheKeys.ForumUrlNames); _cacheHelper.RemoveCacheObject(CacheKeys.ForumPostRoleRestrictions); _cacheHelper.RemoveCacheObject(CacheKeys.ForumViewRoleRestrictions); _cacheHelper.RemoveCacheObject(CacheKeys.ForumTitles); return forum; } public async Task> GetForumsInCategory(int? categoryID) { Task> forums = null; if (categoryID.HasValue && categoryID != 0) await _sqlObjectFactory.GetConnection().UsingAsync(connection => forums = connection.QueryAsync("SELECT " + ForumFields + " FROM pf_Forum WHERE CategoryID = @CategoryID", new { CategoryID = categoryID.Value })); else await _sqlObjectFactory.GetConnection().UsingAsync(connection => forums = connection.QueryAsync("SELECT " + ForumFields + " FROM pf_Forum WHERE CategoryID = 0 OR CategoryID IS NULL")); return forums.Result.ToList(); } public async Task> GetUrlNamesThatStartWith(string urlName) { Task> list = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => list = connection.QueryAsync("SELECT UrlName FROM pf_Forum WHERE UrlName LIKE @UrlName + '%'", new { UrlName = urlName })); return list.Result.ToList(); } public async Task Update(int forumID, int? categoryID, string title, string description, bool isVisible, bool isArchived, string urlName, string forumAdapterName, bool isQAForum) { if (categoryID == 0) categoryID = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_Forum SET CategoryID = @CategoryID, Title = @Title, Description = @Description, IsVisible = @IsVisible, IsArchived = @IsArchived, UrlName = @UrlName, ForumAdapterName = @ForumAdapterName, IsQAForum = @IsQAForum WHERE ForumID = @ForumID", new { CategoryID = categoryID, Title = title, Description = description.NullToEmpty(), IsVisible = isVisible, IsArchived = isArchived, UrlName = urlName, ForumAdapterName = forumAdapterName, IsQAForum = isQAForum, ForumID = forumID })); _cacheHelper.RemoveCacheObject(CacheKeys.ForumUrlNames); _cacheHelper.RemoveCacheObject(CacheKeys.ForumTitles); } public async Task UpdateSortOrder(int forumID, int newSortOrder) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_Forum SET SortOrder = @SortOrder WHERE ForumID = @ForumID", new { SortOrder = newSortOrder, ForumID = forumID })); } public async Task UpdateCategoryAssociation(int forumID, int? categoryID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_Forum SET CategoryID = @CategoryID WHERE ForumID = @ForumID", new { CategoryID = categoryID, ForumID = forumID })); } public async Task UpdateLastTimeAndUser(int forumID, DateTime lastTime, string lastName) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_Forum SET LastPostTime = @LastPostTime, LastPostName = @LastPostName WHERE ForumID = @ForumID", new { LastPostTime = lastTime, LastPostName = lastName, ForumID = forumID })); } public async Task UpdateTopicAndPostCounts(int forumID, int topicCount, int postCount) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_Forum SET TopicCount = @TopicCount, PostCount = @PostCount WHERE ForumID = @ForumID", new { TopicCount = topicCount, PostCount = postCount, ForumID = forumID })); } public async Task IncrementPostCount(int forumID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_Forum SET PostCount = PostCount + 1 WHERE ForumID = @ForumID", new { ForumID = forumID })); } public async Task IncrementPostAndTopicCount(int forumID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_Forum SET TopicCount = TopicCount + 1, PostCount = PostCount + 1 WHERE ForumID = @ForumID", new { ForumID = forumID })); } public async Task> GetAll() { Task> forums = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => forums = connection.QueryAsync("SELECT " + ForumFields + " FROM pf_Forum ORDER BY SortOrder")); return await forums; } public async Task> GetAllVisible() { Task> forums = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => forums = connection.QueryAsync("SELECT " + ForumFields + " FROM pf_Forum WHERE IsVisible = 1 ORDER BY SortOrder")); return await forums; } public async Task> GetForumPostRoles(int forumID) { var restrictions = await GetForumPostRestrictionRoleGraph(); var roles = restrictions.Single(r => r.Key == forumID).Value; return roles; } public async Task> GetForumViewRoles(int forumID) { var restrictions = await GetForumViewRestrictionRoleGraph(); var roles = restrictions.Single(r => r.Key == forumID).Value; return roles; } public async Task>> GetForumPostRestrictionRoleGraph() { var cacheObject = _cacheHelper.GetCacheObject>>(CacheKeys.ForumPostRoleRestrictions); if (cacheObject != null) return cacheObject; var dictionary = await GetForumRestrictionRoleGraph("pf_ForumPostRestrictions"); _cacheHelper.SetLongTermCacheObject(CacheKeys.ForumPostRoleRestrictions, dictionary); return dictionary; } public async Task>> GetForumViewRestrictionRoleGraph() { var cacheObject = _cacheHelper.GetCacheObject>>(CacheKeys.ForumViewRoleRestrictions); if (cacheObject != null) return cacheObject; var dictionary = await GetForumRestrictionRoleGraph("pf_ForumViewRestrictions"); _cacheHelper.SetLongTermCacheObject(CacheKeys.ForumViewRoleRestrictions, dictionary); return dictionary; } private async Task>> GetForumRestrictionRoleGraph(string table) { var dictionary = new Dictionary>(); var forums = await GetAll(); foreach (var forum in forums) dictionary.Add(forum.ForumID, new List()); IEnumerable roleGraph = null; _sqlObjectFactory.GetConnection().Using(connection => roleGraph = connection.Query("SELECT ForumID, Role FROM " + table)); foreach (var item in roleGraph) { dictionary.Single(d => d.Key == item.ForumID).Value .Add(item.Role); } return dictionary; } private class RoleGraph { public int ForumID { get; set; } public string Role { get; set; } } private async Task ModifyForumRole(int forumID, string role, string sql) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync(sql, new { ForumID = forumID, Role = role })); } private async Task ModifyForumRole(int forumID, string sql) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync(sql, new { ForumID = forumID })); } public async Task AddPostRole(int forumID, string role) { await RemovePostRole(forumID, role); await ModifyForumRole(forumID, role, "INSERT INTO pf_ForumPostRestrictions (ForumID, Role) VALUES (@ForumID, @Role)"); _cacheHelper.RemoveCacheObject(CacheKeys.ForumPostRoleRestrictions); } public async Task RemovePostRole(int forumID, string role) { await ModifyForumRole(forumID, role, "DELETE FROM pf_ForumPostRestrictions WHERE ForumID = @ForumID And Role = @Role"); _cacheHelper.RemoveCacheObject(CacheKeys.ForumPostRoleRestrictions); } public async Task AddViewRole(int forumID, string role) { await RemoveViewRole(forumID, role); await ModifyForumRole(forumID, role, "INSERT INTO pf_ForumViewRestrictions (ForumID, Role) VALUES (@ForumID, @Role)"); _cacheHelper.RemoveCacheObject(CacheKeys.ForumViewRoleRestrictions); } public async Task RemoveViewRole(int forumID, string role) { await ModifyForumRole(forumID, role, "DELETE FROM pf_ForumViewRestrictions WHERE ForumID = @ForumID And Role = @Role"); _cacheHelper.RemoveCacheObject(CacheKeys.ForumViewRoleRestrictions); } public async Task RemoveAllPostRoles(int forumID) { await ModifyForumRole(forumID, "DELETE FROM pf_ForumPostRestrictions WHERE ForumID = @ForumID"); _cacheHelper.RemoveCacheObject(CacheKeys.ForumPostRoleRestrictions); } public async Task RemoveAllViewRoles(int forumID) { await ModifyForumRole(forumID, "DELETE FROM pf_ForumViewRestrictions WHERE ForumID = @ForumID"); _cacheHelper.RemoveCacheObject(CacheKeys.ForumViewRoleRestrictions); } public async Task> GetAllForumUrlNames() { var cacheObject = _cacheHelper.GetCacheObject>(CacheKeys.ForumUrlNames); if (cacheObject != null) return cacheObject; Task> urlNames = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => urlNames = connection.QueryAsync("SELECT UrlName FROM pf_Forum")); _cacheHelper.SetLongTermCacheObject(CacheKeys.ForumUrlNames, urlNames.Result); return urlNames.Result; } public Dictionary GetAllForumTitles() { var cacheObject = _cacheHelper.GetCacheObject>(CacheKeys.ForumTitles); if (cacheObject != null) return cacheObject; Dictionary urlNames = null; _sqlObjectFactory.GetConnection().Using(connection => urlNames = connection.Query("SELECT ForumID, Title FROM pf_Forum").ToDictionary(r => (int)r.ForumID, r => (string)r.Title)); _cacheHelper.SetLongTermCacheObject(CacheKeys.ForumTitles, urlNames); return urlNames; } public async Task GetAggregateTopicCount() { var cacheObject = _cacheHelper.GetCacheObject(CacheKeys.AggregateTopicCount); if (cacheObject != null) return cacheObject.Value; Task count = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => count = connection.ExecuteScalarAsync("SELECT SUM(TopicCount) FROM pf_Forum")); _cacheHelper.SetCacheObject(CacheKeys.AggregateTopicCount, count.Result); return count.Result; } public async Task GetAggregatePostCount() { var cacheObject = _cacheHelper.GetCacheObject(CacheKeys.AggregatePostCount); if (cacheObject != null) return cacheObject.Value; Task count = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => count = connection.ExecuteScalarAsync("SELECT SUM(PostCount) FROM pf_Forum")); _cacheHelper.SetCacheObject(CacheKeys.AggregatePostCount, count.Result); return count.Result; } } ================================================ FILE: src/PopForums.Sql/Repositories/IgnoreRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class IgnoreRepository(ISqlObjectFactory sqlObjectFactory) : IIgnoreRepository { public async Task AddIgnore(int userID, int ignoreUserID) { await sqlObjectFactory.GetConnection().UsingAsync(async connection => { await connection.ExecuteAsync( @"INSERT INTO pf_Ignore (UserID, IgnoreUserID) VALUES (@UserID, @IgnoreUserID)", new { UserID = userID, IgnoreUserID = ignoreUserID }); }); } public async Task DeleteIgnore(int userID, int ignoreUserID) { await sqlObjectFactory.GetConnection().UsingAsync(async connection => { await connection.ExecuteAsync( @"DELETE FROM pf_Ignore WHERE UserID = @UserID AND IgnoreUserID = @IgnoreUserID", new { UserID = userID, IgnoreUserID = ignoreUserID }); }); } public async Task> GetIgnoreList(int userID) { Task> result = null; await sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.QueryAsync("SELECT I.UserID, IgnoreUserID, Name FROM pf_Ignore I JOIN pf_PopForumsUser U ON IgnoreUserID = U.UserID WHERE I.UserID = @UserID", new { UserID = userID })); return result.Result.ToList(); } public async Task> GetIgnoredUserIdsInList(int userID, List userIDs) { if (userIDs == null || userIDs.Count == 0) return new List(); var inList = userIDs.Aggregate(string.Empty, (current, id) => current + ("," + id)); if (inList.StartsWith(",")) inList = inList.Remove(0, 1); var sql = $"SELECT IgnoreUserID FROM pf_Ignore WHERE UserID = @UserID AND IgnoreUserID IN ({inList})"; Task> result = null; await sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.QueryAsync(sql, new { UserID = userID })); return result.Result.ToList(); } } ================================================ FILE: src/PopForums.Sql/Repositories/LastReadRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class LastReadRepository : ILastReadRepository { public LastReadRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } private readonly ISqlObjectFactory _sqlObjectFactory; public async Task SetForumRead(int userID, int forumID, DateTime readTime) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_LastForumView WHERE UserID = @UserID AND ForumID = @ForumID; INSERT INTO pf_LastForumView (UserID, ForumID, LastForumViewDate)VALUES (@UserID, @ForumID, @LastForumViewDate)", new { UserID = userID, ForumID = forumID, LastForumViewDate = readTime })); } public async Task DeleteTopicReadsInForum(int userID, int forumID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE pf_LastTopicView FROM pf_LastTopicView JOIN pf_Topic ON pf_LastTopicView.TopicID = pf_Topic.TopicID WHERE pf_Topic.ForumID = @ForumID AND pf_LastTopicView.UserID = @UserID", new { UserID = userID, ForumID = forumID })); } public async Task SetAllForumsRead(int userID, DateTime readTime) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_LastForumView WHERE UserID = @UserID; INSERT INTO pf_LastForumView SELECT @UserID, ForumID, @LastForumViewDate FROM pf_Forum", new { UserID = userID, LastForumViewDate = readTime })); } public async Task DeleteAllTopicReads(int userID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_LastTopicView WHERE UserID = @UserID", new { UserID = userID })); } public async Task SetTopicRead(int userID, int topicID, DateTime readTime) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_LastTopicView WHERE UserID = @UserID AND TopicID = @TopicID", new { UserID = userID, TopicID = topicID })); await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_LastTopicView (UserID, TopicID, LastTopicViewDate) VALUES (@UserID, @TopicID, @LastTopicViewDate)", new { UserID = userID, TopicID = topicID, LastTopicViewDate = readTime })); } public async Task> GetLastReadTimesForForums(int userID) { Task>> dictionary = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => dictionary = connection.QueryAsync>("SELECT ForumID AS [Key], LastForumViewDate AS [Value] FROM pf_LastForumView WHERE UserID = @UserID", new { UserID = userID })); return dictionary.Result.ToDictionary(p => p.Key, p => p.Value); } public async Task GetLastReadTimesForForum(int userID, int forumID) { Task lastRead = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => lastRead = connection.QuerySingleOrDefaultAsync("SELECT LastForumViewDate FROM pf_LastForumView WHERE UserID = @UserID AND ForumID = @ForumID", new { UserID = userID, ForumID = forumID })); return lastRead.Result; } public async Task> GetLastReadTimesForTopics(int userID, IEnumerable topicIDs) { var dictionary = new Dictionary(); if (!topicIDs.Any()) return dictionary; var inString = new StringBuilder(); bool isFirst = true; foreach (var topicID in topicIDs) { if (!isFirst) inString.Append(", "); isFirst = false; inString.Append(topicID); } var sql = $"SELECT TopicID, LastTopicViewDate FROM pf_LastTopicView WHERE UserID = @UserID AND TopicID IN ({inString})"; await _sqlObjectFactory.GetConnection().UsingAsync(async connection => { var reader = await connection.ExecuteReaderAsync(sql, new {UserID = userID}); while (reader.Read()) { var key = reader.GetInt32(0); if (!dictionary.ContainsKey(key)) dictionary.Add(key, reader.GetDateTime(1)); } }); return dictionary; } public async Task GetLastReadTimeForTopic(int userID, int topicID) { Task time = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => time = connection.QueryFirstOrDefaultAsync("SELECT LastTopicViewDate FROM pf_LastTopicView WHERE UserID = @UserID AND TopicID = @TopicID", new { UserID = userID, TopicID = topicID })); return time.Result; } } ================================================ FILE: src/PopForums.Sql/Repositories/ModerationLogRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class ModerationLogRepository : IModerationLogRepository { public ModerationLogRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } private readonly ISqlObjectFactory _sqlObjectFactory; public async Task Log(DateTime timeStamp, int userID, string userName, int moderationType, int? forumID, int topicID, int? postID, string comment, string oldText) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_ModerationLog (TimeStamp, UserID, UserName, ModerationType, ForumID, TopicID, PostID, Comment, OldText) VALUES (@TimeStamp, @UserID, @UserName, @ModerationType, @ForumID, @TopicID, @PostID, @Comment, @OldText)", new { TimeStamp = timeStamp, UserID = userID, UserName = userName, ModerationType = moderationType, ForumID = forumID, TopicID = topicID, PostID = postID, Comment = comment.NullToEmpty(), OldText = oldText })); } public async Task> GetLog(DateTime start, DateTime end) { Task> list = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => list = connection.QueryAsync("SELECT ModerationID, TimeStamp, UserID, UserName, ModerationType, ForumID, TopicID, PostID, Comment, OldText FROM pf_ModerationLog WHERE TimeStamp >= @Start AND TimeStamp <= @End ORDER BY TimeStamp", new { Start = start, End = end })); return list.Result.ToList(); } public async Task> GetLog(int topicID, bool excludePostEntries) { Task> list = null; var sql = "SELECT ModerationID, TimeStamp, UserID, UserName, ModerationType, ForumID, TopicID, PostID, Comment, OldText FROM pf_ModerationLog WHERE TopicID = @TopicID"; if (excludePostEntries) sql += " AND PostID IS NULL"; sql += " ORDER BY TimeStamp"; await _sqlObjectFactory.GetConnection().UsingAsync(connection => list = connection.QueryAsync(sql, new { TopicID = topicID })); return list.Result.ToList(); } public async Task> GetLog(int postID) { Task> list = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => list = connection.QueryAsync("SELECT ModerationID, TimeStamp, UserID, UserName, ModerationType, ForumID, TopicID, PostID, Comment, OldText FROM pf_ModerationLog WHERE PostID = @PostID ORDER BY TimeStamp", new { PostID = postID })); return list.Result.ToList(); } } ================================================ FILE: src/PopForums.Sql/Repositories/NotificationRepository.cs ================================================ using PopForums.Messaging; namespace PopForums.Sql.Repositories; public class NotificationRepository : INotificationRepository { private readonly ISqlObjectFactory _sqlObjectFactory; private readonly ICacheHelper _cacheHelper; public NotificationRepository(ISqlObjectFactory sqlObjectFactory, ICacheHelper cacheHelper) { _sqlObjectFactory = sqlObjectFactory; _cacheHelper = cacheHelper; SqlMapper.AddTypeHandler(new JsonElementTypeHandler()); } private string GetCacheKey(int userID) { return "PopForums.NewNotifications." + userID; } private void RemoveCache(int userID) { var key = GetCacheKey(userID); _cacheHelper.RemoveCacheObject(key); } public async Task UpdateNotification(Notification notification) { Task total = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => total = connection.ExecuteAsync("UPDATE pf_Notifications SET TimeStamp = @TimeStamp, IsRead = @IsRead, Data = @Data WHERE UserID = @UserID AND NotificationType = @NotificationType AND ContextID = @ContextID", notification)); RemoveCache(notification.UserID); return await total; } public async Task CreateNotification(Notification notification) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_Notifications (UserID, NotificationType, ContextID, TimeStamp, IsRead, Data) VALUES (@UserID, @NotificationType, @ContextID, @TimeStamp, @IsRead, @Data)", notification)); RemoveCache(notification.UserID); } public async Task MarkNotificationRead(int userID, NotificationType notificationType, long contextID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_Notifications SET IsRead = 1 WHERE UserID = @userID AND NotificationType = @notificationType AND ContextID = @contextID", new { userID, notificationType, contextID })); RemoveCache(userID); } public async Task> GetNotifications(int userID, DateTime afterDateTime, int pageSize) { var sql = $"SELECT TOP {pageSize} * FROM pf_Notifications WHERE UserID = @userID AND [TimeStamp] < @afterDateTime ORDER BY [TimeStamp] DESC"; Task> notifications = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => notifications = connection.QueryAsync(sql, new { userID, afterDateTime })); return notifications.Result.ToList(); } public async Task GetPageCount(int userID, int pageSize) { Task count = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => count = connection.QuerySingleAsync("SELECT COUNT(*) FROM pf_Notifications WHERE UserID = @userID", new { userID })); var notificationCount = count.Result; if (notificationCount <= pageSize) return 1; var pageCount = Math.Ceiling(notificationCount / pageSize); return Convert.ToInt32(pageCount); } public async Task GetUnreadNotificationCount(int userID) { var key = GetCacheKey(userID); var cachedItem = _cacheHelper.GetCacheObject(key); if (cachedItem != null) return cachedItem.Value; Task count = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => count = connection.QuerySingleAsync("SELECT COUNT(*) FROM pf_Notifications WHERE UserID = @userID AND IsRead = 0", new { userID })); _cacheHelper.SetCacheObject(key, count.Result); return count.Result; } public async Task MarkAllRead(int userID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_Notifications SET IsRead = 1 WHERE UserID = @userID", new { userID })); RemoveCache(userID); } public async Task DeleteOlderThan(int userID, DateTime timeCutOff) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_Notifications WHERE UserID = @UserID AND TimeStamp < @TimeStamp", new { UserID = userID, TimeStamp = timeCutOff })); } } ================================================ FILE: src/PopForums.Sql/Repositories/PointLedgerRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class PointLedgerRepository : IPointLedgerRepository { public PointLedgerRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } private readonly ISqlObjectFactory _sqlObjectFactory; public virtual async Task RecordEntry(PointLedgerEntry entry) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_PointLedger (UserID, EventDefinitionID, Points, TimeStamp) VALUES (@UserID, @EventDefinitionID, @Points, @TimeStamp)", new { entry.UserID, entry.EventDefinitionID, entry.Points, entry.TimeStamp })); } public async Task GetPointTotal(int userID) { Task total = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => total = connection.ExecuteScalarAsync("SELECT SUM(Points) FROM pf_PointLedger WHERE UserID = @UserID", new { UserID = userID })); return await total; } public async Task GetEntryCount(int userID, string eventDefinitionID) { Task total = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => total = connection.ExecuteScalarAsync("SELECT COUNT(*) FROM pf_PointLedger WHERE UserID = @UserID AND EventDefinitionID = @EventDefinitionID", new { UserID = userID, EventDefinitionID = eventDefinitionID })); return await total; } } ================================================ FILE: src/PopForums.Sql/Repositories/PostImageRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class PostImageRepository : IPostImageRepository { private readonly ISqlObjectFactory _sqlObjectFactory; private readonly ITenantService _tenantService; public PostImageRepository(ISqlObjectFactory sqlObjectFactory, ITenantService tenantService) { _sqlObjectFactory = sqlObjectFactory; _tenantService = tenantService; } public async Task Persist(byte[] bytes, string contentType) { var guid = Guid.NewGuid(); var tenantID = _tenantService.GetTenant(); var postImage = new PostImage { ID = guid.ToString(), TimeStamp = DateTime.UtcNow, ContentType = contentType, TenantID = tenantID, ImageData = bytes }; await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_PostImage (ID, TimeStamp, ContentType, TenantID, ImageData) VALUES (@ID, @TimeStamp, @ContentType, @TenantID, @ImageData)", postImage)); var url = "/Forums/Image/PostImage/" + postImage.ID; var payload = new PostImagePersistPayload {Url = url, ID = postImage.ID}; return payload; } public async Task DeletePostImageData(string id, string tenantID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_PostImage WHERE ID = @id", new {id})); } public async Task GetWithoutData(string id) { Task image = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => image = connection.QuerySingleOrDefaultAsync("SELECT ID, TimeStamp, ContentType, TenantID FROM pf_PostImage WHERE ID = @id", new { id })); return await image; } [Obsolete("Use the combination of GetWithoutData(int) and GetImageStream(int) instead.")] public async Task Get(string id) { Task image = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => image = connection.QuerySingleOrDefaultAsync("SELECT * FROM pf_PostImage WHERE ID = @id", new {id})); return await image; } public async Task GetImageStream(string id) { var connection = (SqlConnection)_sqlObjectFactory.GetConnection(); var command = new SqlCommand("SELECT ImageData FROM pf_PostImage WHERE ID = @id", connection); command.Parameters.AddWithValue("id", id); connection.Open(); var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess); if (await reader.ReadAsync() && !await reader.IsDBNullAsync(0)) { var stream = reader.GetStream(0); var streamResponse = new StreamResponse(stream, connection, reader); return streamResponse; } return default; } } ================================================ FILE: src/PopForums.Sql/Repositories/PostImageTempRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class PostImageTempRepository : IPostImageTempRepository { private readonly ISqlObjectFactory _sqlObjectFactory; public PostImageTempRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } public async Task Save(Guid postImageTempID, DateTime timeStamp, string tenantID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_PostImageTemp (PostImageTempID, TimeStamp, TenantID) VALUES (@postImageTempID, @timeStamp, @tenantID)", new { postImageTempID, timeStamp, tenantID })); } public async Task Delete(Guid id) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_PostImageTemp WHERE PostImageTempID = @id", new { id })); } public async Task> GetOld(DateTime olderThan) { Task> list = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => list = connection.QueryAsync("SELECT PostImageTempID FROM pf_PostImageTemp WHERE TimeStamp < @olderThan", new { olderThan })); return list.Result.ToList(); } } ================================================ FILE: src/PopForums.Sql/Repositories/PostRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class PostRepository : IPostRepository { public PostRepository(ISqlObjectFactory sqlObjectFactory, ICacheHelper cache) { _sqlObjectFactory = sqlObjectFactory; _cache = cache; } public class CacheKeys { public const string PostPages = "PopForums.PostPages.{0}"; } private readonly ISqlObjectFactory _sqlObjectFactory; private readonly ICacheHelper _cache; private const string PostFields = "PostID, TopicID, ParentPostID, IP, IsFirstInTopic, ShowSig, UserID, Name, Title, FullText, PostTime, IsEdited, LastEditName, LastEditTime, IsDeleted, Votes"; public virtual async Task Create(int topicID, int parentPostID, string ip, bool isFirstInTopic, bool showSig, int userID, string name, string title, string fullText, DateTime postTime, bool isEdited, string lastEditName, DateTime? lastEditTime, bool isDeleted, int votes) { Task postID = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => postID = connection.QuerySingleAsync("INSERT INTO pf_Post (TopicID, ParentPostID, IP, IsFirstInTopic, ShowSig, UserID, Name, Title, FullText, PostTime, IsEdited, LastEditName, LastEditTime, IsDeleted, Votes) VALUES (@TopicID, @ParentPostID, @IP, @IsFirstInTopic, @ShowSig, @UserID, @Name, @Title, @FullText, @PostTime, @IsEdited, @LastEditName, @LastEditTime, @IsDeleted, @Votes);SELECT CAST(SCOPE_IDENTITY() as int)", new { TopicID = topicID, ParentPostID = parentPostID, IP = ip, IsFirstInTopic = isFirstInTopic, ShowSig = showSig, UserID = userID, Name = name, Title = title, FullText = fullText, PostTime = postTime, IsEdited = isEdited, LastEditTime = lastEditTime, LastEditName = lastEditName, IsDeleted = isDeleted, Votes = votes })); var key = string.Format(CacheKeys.PostPages, topicID); _cache.RemoveCacheObject(key); return await postID; } public async Task Update(Post post) { Task result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.ExecuteAsync("UPDATE pf_Post SET TopicID = @TopicID, ParentPostID = @ParentPostID, IP = @IP, IsFirstInTopic = @IsFirstInTopic, ShowSig = @ShowSig, UserID = @UserID, Name = @Name, Title = @Title, FullText = @FullText, PostTime = @PostTime, IsEdited = @IsEdited, LastEditName = @LastEditName, LastEditTime = @LastEditTime, IsDeleted = @IsDeleted, Votes = @Votes WHERE PostID = @PostID", new { post.TopicID, post.ParentPostID, post.IP, post.IsFirstInTopic, post.ShowSig, post.UserID, post.Name, post.Title, post.FullText, post.PostTime, post.IsEdited, post.LastEditTime, post.LastEditName, post.IsDeleted, post.Votes, post.PostID })); var key = string.Format(CacheKeys.PostPages, post.TopicID); _cache.RemoveCacheObject(key); return result.Result == 1; } public async Task> Get(int topicID, bool includeDeleted, int startRow, int pageSize) { var key = string.Format(CacheKeys.PostPages, topicID); var page = startRow == 1 ? 1 : (startRow - 1) / pageSize + 1; if (!includeDeleted) { // we're only caching paged threads that do not include deleted posts, since only moderators // ever see threads that way, a small percentage of users var cachedList = _cache.GetPagedListCacheObject(key, page); if (cachedList != null) return cachedList; } const string sql = @" DECLARE @Counter int SET @Counter = (@StartRow + @PageSize - 1) SET ROWCOUNT @Counter; WITH Entries AS ( SELECT ROW_NUMBER() OVER (ORDER BY PostTime) AS Row, PostID, TopicID, ParentPostID, IP, IsFirstInTopic, ShowSig, UserID, Name, Title, FullText, PostTime, IsEdited, LastEditName, LastEditTime, IsDeleted, Votes FROM pf_Post WHERE TopicID = @TopicID AND ((@IncludeDeleted = 1) OR (@IncludeDeleted = 0 AND IsDeleted = 0))) SELECT PostID, TopicID, ParentPostID, IP, IsFirstInTopic, ShowSig, UserID, Name, Title, FullText, PostTime, IsEdited, LastEditName, LastEditTime, IsDeleted, Votes FROM Entries WHERE Row between @StartRow and @StartRow + @PageSize - 1 SET ROWCOUNT 0"; Task> posts = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => posts = connection.QueryAsync(sql, new { TopicID = topicID, IncludeDeleted = includeDeleted, StartRow = startRow, PageSize = pageSize })); var list = posts.Result.ToList(); if (!includeDeleted) { _cache.SetPagedListCacheObject(key, page, list); } return list; } public async Task> Get(int topicID, bool includeDeleted) { const string sql = "SELECT PostID, TopicID, ParentPostID, IP, IsFirstInTopic, ShowSig, UserID, Name, Title, FullText, PostTime, IsEdited, LastEditName, LastEditTime, IsDeleted, Votes FROM pf_Post WHERE TopicID = @TopicID AND ((@IncludeDeleted = 1) OR (@IncludeDeleted = 0 AND IsDeleted = 0)) ORDER BY PostTime"; Task> posts = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => posts = connection.QueryAsync(sql, new { TopicID = topicID, IncludeDeleted = includeDeleted })); return posts.Result.ToList(); } public async Task GetLastInTopic(int topicID) { Task post = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => post = connection.QuerySingleOrDefaultAsync("SELECT TOP 1 " + PostFields + " FROM pf_Post WHERE TopicID = @TopicID AND IsDeleted = 0 ORDER BY PostTime DESC", new { TopicID = topicID})); return await post; } public async Task GetReplyCount(int topicID, bool includeDeleted) { var sql = "SELECT COUNT(*) FROM pf_Post WHERE TopicID = @TopicID"; if (!includeDeleted) sql += " AND IsDeleted = 0 AND IsFirstInTopic = 0"; Task replyCount = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => replyCount = connection.ExecuteScalarAsync(sql, new { TopicID = topicID })); return await replyCount; } public async Task Get(int postID) { Task post = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => post = connection.QuerySingleOrDefaultAsync("SELECT " + PostFields + " FROM pf_Post WHERE PostID = @PostID", new { PostID = postID })); return await post; } public async Task> GetPostIDsWithTimes(int topicID, bool includeDeleted) { Task> results = null; var sql = "SELECT PostID, PostTime FROM pf_Post WHERE TopicID = @TopicID"; if (!includeDeleted) sql += " AND IsDeleted = 0"; sql += " ORDER BY PostTime"; await _sqlObjectFactory.GetConnection().UsingAsync(connection => results = connection.QueryAsync(sql, new { TopicID = topicID })); var dictionary = results.Result.ToDictionary(r => (int) r.PostID, r => (DateTime) r.PostTime); return dictionary; } public async Task GetPostCount(int userID) { Task postCount = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => postCount = connection.ExecuteScalarAsync("SELECT COUNT(PostID) FROM pf_Post JOIN pf_Topic ON pf_Post.TopicID = pf_Topic.TopicID WHERE pf_Post.UserID = @UserID AND pf_Post.IsDeleted = 0 AND pf_Topic.IsDeleted = 0", new { UserID = userID })); return await postCount; } public async Task> GetIPHistory(string ip, DateTime start, DateTime end) { Task> events = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => events = connection.QueryAsync("SELECT PostID AS ID, PostTime AS EventTime, UserID, Name, Title AS Description FROM pf_Post WHERE IP = @IP AND PostTime >= @Start AND PostTime <= @End", new { IP = ip, Start = start, End = end })); var list = events.Result.ToList(); foreach (var item in list) item.Type = "Post"; return list; } public async Task GetLastPostID(int topicID) { const string sql = "SELECT PostID FROM pf_Post WHERE TopicID = @TopicID AND IsDeleted = 0 ORDER BY PostTime DESC"; Task id = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => id = connection.QuerySingleOrDefaultAsync(sql, new { TopicID = topicID })); return await id; } public async Task GetVoteCount(int postID) { const string sql = "SELECT Votes FROM pf_Post WHERE PostID = @PostID"; Task votes = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => votes = connection.QuerySingleOrDefaultAsync(sql, new { PostID = postID })); return await votes; } public async Task CalculateVoteCount(int postID) { const string sql = "SELECT COUNT(*) FROM pf_PostVote WHERE PostID = @PostID"; Task count = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => count = connection.ExecuteScalarAsync(sql, new { PostID = postID })); return await count; } public async Task SetVoteCount(int postID, int votes) { const string sql = "UPDATE pf_Post SET Votes = @Votes WHERE PostID = @PostID"; await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync(sql, new { Votes = votes, PostID = postID })); } public async Task VotePost(int postID, int userID) { const string sql = "INSERT INTO pf_PostVote (PostID, UserID) VALUES (@PostID, @UserID)"; await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync(sql, new { PostID = postID, UserID = userID })); } public async Task> GetVotes(int postID) { Task> results = null; const string sql = "SELECT V.UserID, U.Name FROM pf_PostVote V LEFT JOIN pf_PopForumsUser U ON V.UserID = U.UserID WHERE V.PostID = @PostID"; await _sqlObjectFactory.GetConnection().UsingAsync(connection => results = connection.QueryAsync(sql, new { PostID = postID })); var dictionary = results.Result.ToDictionary(r => (int) r.UserID, r => (string) r.Name); return dictionary; } public async Task> GetVotedPostIDs(int userID, List postIDs) { Task> result = null; if (postIDs.Count == 0) return new List(); var inList = postIDs.Aggregate(string.Empty, (current, postID) => current + ("," + postID)); if (inList.StartsWith(",")) inList = inList.Remove(0, 1); var sql = $"SELECT PostID FROM pf_PostVote WHERE PostID IN ({inList}) AND UserID = @UserID"; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.QueryAsync(sql, new { UserID = userID })); var list = result.Result.ToList(); return list; } public async Task DeleteVote(int postID, int userID) { const string sql = "DELETE FROM pf_PostVote WHERE PostID = @postID AND UserID = @userID"; await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync(sql, new { postID, userID })); } } ================================================ FILE: src/PopForums.Sql/Repositories/PrivateMessageRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class PrivateMessageRepository : IPrivateMessageRepository { public PrivateMessageRepository(ICacheHelper cacheHelper, ISqlObjectFactory sqlObjectFactory) { _cacheHelper = cacheHelper; _sqlObjectFactory = sqlObjectFactory; SqlMapper.AddTypeHandler(new JsonElementTypeHandler()); } private readonly ICacheHelper _cacheHelper; private readonly ISqlObjectFactory _sqlObjectFactory; public class CacheKeys { public static string PMCount(int userID) { return "PopForums.PrivateMessages.Count." + userID; } } public async Task Get(int pmID, int userID) { Task pm = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => pm = connection.QuerySingleOrDefaultAsync("SELECT P.PMID, LastPostTime, Users, U.LastViewDate FROM pf_PrivateMessage P JOIN pf_PrivateMessageUser U ON P.PMID = U.PMID WHERE P.PMID = @pmID AND U.UserID = @userID", new { pmID, userID })); return await pm; } public async Task GetExistingFromIDs(List ids) { Task result = null; var count = ids.Count; var array = string.Join(", ", ids); var sql = @$"SELECT PMID FROM pf_PrivateMessageUser GROUP BY PMID HAVING SUM(CASE WHEN UserID IN ({array}) THEN 1 ELSE 0 END) = {count} AND COUNT(*) = {count}"; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.QueryFirstOrDefaultAsync(sql)); return await result; } public async Task> GetPosts(int pmID, DateTime afterDateTime) { var sql = $"SELECT * FROM pf_PrivateMessagePost WHERE PMID = @pmID AND PostTime > @afterDateTime ORDER BY PostTime"; Task> result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.QueryAsync(sql, new { pmID, afterDateTime })); return result.Result.ToList(); } public async Task> GetPosts(int pmID, DateTime beforeDateTime, int pageSize) { var sql = $"SELECT TOP {pageSize} * FROM pf_PrivateMessagePost WHERE PMID = @pmID AND PostTime < @beforeDateTime ORDER BY PostTime DESC"; Task> result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.QueryAsync(sql, new { pmID, beforeDateTime })); var list = result.Result.Reverse().ToList(); return list; } public virtual async Task CreatePrivateMessage(PrivateMessage pm) { Task id = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => id = connection.QuerySingleAsync("INSERT INTO pf_PrivateMessage (LastPostTime, Users) VALUES (@LastPostTime, @Users);SELECT CAST(SCOPE_IDENTITY() as int)", new { pm.LastPostTime, pm.Users })); pm.PMID = await id; return pm.PMID; } public async Task AddUsers(int pmID, List userIDs, DateTime viewDate, bool isArchived) { foreach (var id in userIDs) { _cacheHelper.RemoveCacheObject(CacheKeys.PMCount(id)); await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_PrivateMessageUser (PMID, UserID, LastViewDate, IsArchived) VALUES (@PMID, @UserID, @LastViewDate, @IsArchived)", new { PMID = pmID, UserID = id, LastViewDate = viewDate, IsArchived = isArchived })); } } public virtual async Task AddPost(PrivateMessagePost post) { var users = await GetUsers(post.PMID); foreach (var user in users) _cacheHelper.RemoveCacheObject(CacheKeys.PMCount(user.UserID)); Task id = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => id = connection.QuerySingleAsync("INSERT INTO pf_PrivateMessagePost (PMID, UserID, Name, PostTime, FullText) VALUES (@PMID, @UserID, @Name, @PostTime, @FullText);SELECT CAST(SCOPE_IDENTITY() as int)", new { post.PMID, post.UserID, post.Name, post.PostTime, post.FullText })); post.PMPostID = await id; return post.PMPostID; } public async Task> GetUsers(int pmID) { Task> list = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => list = connection.QueryAsync("SELECT PMID, UserID, LastViewDate, IsArchived FROM pf_PrivateMessageUser WHERE PMID = @PMID", new { PMID = pmID })); return list.Result.ToList(); } public async Task SetLastViewTime(int pmID, int userID, DateTime viewDate) { _cacheHelper.RemoveCacheObject(CacheKeys.PMCount(userID)); await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_PrivateMessageUser SET LastViewDate = @LastViewDate WHERE UserID = @UserID AND PMID = @PMID", new { LastViewDate = viewDate, UserID = userID, PMID = pmID })); } public async Task SetArchive(int pmID, int userID, bool isArchived) { _cacheHelper.RemoveCacheObject(CacheKeys.PMCount(userID)); await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_PrivateMessageUser SET IsArchived = @IsArchived WHERE UserID = @UserID AND PMID = @PMID", new { IsArchived = isArchived, UserID = userID, PMID = pmID })); } public async Task> GetPrivateMessages(int userID, PrivateMessageBoxType boxType, int startRow, int pageSize) { var isArchived = boxType == PrivateMessageBoxType.Archive; const string sql = @"DECLARE @Counter int SET @Counter = (@StartRow + @PageSize - 1) SET ROWCOUNT @Counter; WITH Entries AS ( SELECT ROW_NUMBER() OVER (ORDER BY [LastPostTime] DESC) AS Row, P.PMID, LastPostTime, Users, U.LastViewDate FROM pf_PrivateMessage P JOIN pf_PrivateMessageUser U ON P.PMID = U.PMID WHERE U.UserID = @UserID AND U.IsArchived = @IsArchived) SELECT PMID, LastPostTime, Users, LastViewDate FROM Entries WHERE Row between @StartRow and @StartRow + @PageSize - 1 SET ROWCOUNT 0"; Task> messsages = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => messsages = connection.QueryAsync(sql, new { StartRow = startRow, PageSize = pageSize, UserID = userID, IsArchived = isArchived })); return messsages.Result.ToList(); } public async Task GetBoxCount(int userID, PrivateMessageBoxType boxType) { var isArchived = boxType == PrivateMessageBoxType.Archive; var sql = "SELECT COUNT(*) FROM pf_PrivateMessage P JOIN pf_PrivateMessageUser U ON P.PMID = U.PMID WHERE U.UserID = @UserID AND U.IsArchived = @IsArchived"; Task count = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => count = connection.ExecuteScalarAsync(sql, new { UserID = userID, IsArchived = isArchived })); return await count; } public async Task GetUnreadCount(int userID) { var cacheObject = _cacheHelper.GetCacheObject(CacheKeys.PMCount(userID)); if (cacheObject.HasValue) return cacheObject.Value; Task count = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => count = connection.ExecuteScalarAsync("SELECT COUNT(P.PMID) FROM pf_PrivateMessage P JOIN pf_PrivateMessageUser U ON P.PMID = U.PMID WHERE LastPostTime > LastViewDate AND U.UserID = @UserID AND U.IsArchived = 0", new { UserID = userID })); _cacheHelper.SetCacheObject(CacheKeys.PMCount(userID), count.Result); return await count; } public async Task UpdateLastPostTime(int pmID, DateTime lastPostTime) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_PrivateMessage SET LastPostTime = @LastPostTime WHERE PMID = @PMID", new { LastPostTime = lastPostTime, PMID = pmID })); } public async Task GetFirstUnreadPostID(int pmID, DateTime lastReadTime) { Task pmPostID = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => pmPostID = connection.QueryFirstOrDefaultAsync("SELECT TOP 1 PMPostID FROM pf_PrivateMessagePost WHERE PostTime > @lastReadTime AND PMID = @pmID ORDER BY PostTime", new { lastReadTime, pmID })); return await pmPostID; } } ================================================ FILE: src/PopForums.Sql/Repositories/ProfileRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class ProfileRepository : IProfileRepository { public ProfileRepository(ICacheHelper cacheHelper, ISqlObjectFactory sqlObjectFactory) { _cacheHelper = cacheHelper; _sqlObjectFactory = sqlObjectFactory; } private readonly ICacheHelper _cacheHelper; private readonly ISqlObjectFactory _sqlObjectFactory; public class CacheKeys { public static string UserProfile(int userID) { return "PopForums.Profile.User." + userID; } } public async Task GetProfile(int userID) { var key = CacheKeys.UserProfile(userID); var cachedItem = _cacheHelper.GetCacheObject(key); if (cachedItem != null) return cachedItem; Task profile = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => profile = connection.QuerySingleOrDefaultAsync("SELECT UserID, IsSubscribed, Signature, ShowDetails, Location, IsPlainText, DOB, Web, Facebook, Instagram, IsTos, AvatarID, ImageID, HideVanity, LastPostID, Points, IsAutoFollowOnReply FROM pf_Profile WHERE UserID = @UserID", new { UserID = userID })); if (profile.Result != null) _cacheHelper.SetCacheObject(key, profile.Result); return await profile; } public async Task Create(Profile profile) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_Profile (UserID, IsSubscribed, Signature, ShowDetails, Location, IsPlainText, DOB, Web, Facebook, Instagram, IsTos, AvatarID, ImageID, HideVanity, LastPostID, Points, IsAutoFollowOnReply) VALUES (@UserID, @IsSubscribed, @Signature, @ShowDetails, @Location, @IsPlainText, @DOB, @Web, @Facebook, @Instagram, @IsTos, @AvatarID, @ImageID, @HideVanity, @LastPostID, @Points, @IsAutoFollowOnReply)", new { profile.UserID, profile.IsSubscribed, Signature = profile.Signature.NullToEmpty(), profile.ShowDetails, Location = profile.Location.NullToEmpty(), profile.IsPlainText, profile.Dob, Web = profile.Web.NullToEmpty(), Instagram = profile.Instagram.NullToEmpty(), Facebook = profile.Facebook.NullToEmpty(), profile.IsTos, profile.AvatarID, profile.ImageID, profile.HideVanity, profile.LastPostID, profile.Points, profile.IsAutoFollowOnReply })); } public async Task Update(Profile profile) { Task success = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => success = connection.ExecuteAsync("UPDATE pf_Profile SET IsSubscribed = @IsSubscribed, Signature = @Signature, ShowDetails = @ShowDetails, Location = @Location, IsPlainText = @IsPlainText, DOB = @DOB, Web = @Web, Facebook = @Facebook, Instagram = @Instagram, IsTos = @IsTos, AvatarID = @AvatarID, ImageID = @ImageID, HideVanity = @HideVanity, LastPostID = @LastPostID, Points = @Points, IsAutoFollowOnReply = @IsAutoFollowOnReply WHERE UserID = @UserID", new { profile.UserID, profile.IsSubscribed, Signature = profile.Signature.NullToEmpty(), profile.ShowDetails, Location = profile.Location.NullToEmpty(), profile.IsPlainText, profile.Dob, Web = profile.Web.NullToEmpty(), Instagram = profile.Instagram.NullToEmpty(), Facebook = profile.Facebook.NullToEmpty(), IsTos = profile.IsTos, profile.AvatarID, profile.ImageID, profile.HideVanity, profile.LastPostID, profile.Points, profile.IsAutoFollowOnReply })); _cacheHelper.RemoveCacheObject(CacheKeys.UserProfile(profile.UserID)); return success.Result == 1; } public async Task GetLastPostID(int userID) { Task postID = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => postID = connection.QuerySingleOrDefaultAsync("SELECT LastPostID FROM pf_Profile WHERE UserID = @UserID", new { UserID = userID })); return await postID; } public async Task SetLastPostID(int userID, int postID) { Task success = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => success = connection.ExecuteAsync("UPDATE pf_Profile SET LastPostID = @LastPostID WHERE UserID = @UserID", new { LastPostID = postID, UserID = userID })); return success.Result == 1; } public async Task> GetSignatures(List userIDs) { if (userIDs.Count == 0) return new Dictionary(); var inList = userIDs.Aggregate(string.Empty, (current, userID) => current + ("," + userID)); if (inList.StartsWith(",")) inList = inList.Remove(0, 1); var sql = $"SELECT UserID, Signature FROM pf_Profile WHERE NOT Signature = '' AND UserID IN ({inList})"; Task> dictionary = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => dictionary = connection.QueryAsync(sql)); return dictionary.Result.ToDictionary(r => (int)r.UserID, r => (string)r.Signature); } public async Task> GetAvatars(List userIDs) { if (userIDs.Count == 0) return new Dictionary(); var inList = userIDs.Aggregate(string.Empty, (current, userID) => current + ("," + userID)); if (inList.StartsWith(",")) inList = inList.Remove(0, 1); var sql = $"SELECT UserID, AvatarID FROM pf_Profile WHERE NOT AvatarID IS NULL AND UserID IN ({inList})"; Task> dictionary = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => dictionary = connection.QueryAsync(sql)); return dictionary.Result.ToDictionary(r => (int)r.UserID, r => (int)r.AvatarID); } public async Task SetCurrentImageIDToNull(int userID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_Profile SET ImageID = NULL WHERE UserID = @UserID", new { UserID = userID })); _cacheHelper.RemoveCacheObject(CacheKeys.UserProfile(userID)); } public async Task UpdatePoints(int userID, int points) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_Profile SET Points = @Points WHERE UserID = @UserID", new { UserID = userID, Points = points })); _cacheHelper.RemoveCacheObject(CacheKeys.UserProfile(userID)); } } ================================================ FILE: src/PopForums.Sql/Repositories/QueuedEmailMessageRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class QueuedEmailMessageRepository : IQueuedEmailMessageRepository { public QueuedEmailMessageRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } private readonly ISqlObjectFactory _sqlObjectFactory; public async Task CreateMessage(QueuedEmailMessage message) { Task id = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => id = connection.QuerySingleAsync("INSERT INTO pf_QueuedEmailMessage (FromName, ToEmail, ToName, Subject, Body, HtmlBody, QueueTime) VALUES (@FromName, @ToEmail, @ToName, @Subject, @Body, @HtmlBody, @QueueTime);SELECT CAST(SCOPE_IDENTITY() as int)", new { message.FromName, message.ToEmail, message.ToName, message.Subject, message.Body, message.HtmlBody, message.QueueTime })); if (id.Result == 0) throw new Exception("MessageID was not returned from creation of a QueuedEmailMessage."); return await id; } public async Task DeleteMessage(int messageID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_QueuedEmailMessage WHERE MessageID = @MessageID", new { MessageID = messageID })); } public async Task GetMessage(int messageID) { Task message = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => message = connection.QuerySingleOrDefaultAsync("SELECT MessageID, FromName, ToEmail, ToName, Subject, Body, HtmlBody, QueueTime FROM pf_QueuedEmailMessage WHERE MessageID = @MessageID", new {messageID})); return await message; } } ================================================ FILE: src/PopForums.Sql/Repositories/RoleRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class RoleRepository : IRoleRepository { public RoleRepository(ICacheHelper cacheHelper, ISqlObjectFactory sqlObjectFactory) { _cacheHelper = cacheHelper; _sqlObjectFactory = sqlObjectFactory; } private readonly ICacheHelper _cacheHelper; private readonly ISqlObjectFactory _sqlObjectFactory; public class CacheKeys { public const string AllRoles = "PopForums.Roles.All"; public static string UserRole(int userID) { return "PopForums.Roles.User." + userID; } } public async Task CreateRole(string role) { Task> exists = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => exists = connection.QueryAsync("SELECT Role FROM pf_Role WHERE Role LIKE @Role", new { Role = role })); if (exists.Result.Any()) return; await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_Role (Role) VALUES (@Role)", new { Role = role })); _cacheHelper.RemoveCacheObject(CacheKeys.AllRoles); } public async Task DeleteRole(string role) { Task result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.ExecuteAsync("DELETE FROM pf_Role WHERE Role = @Role", new { Role = role })); _cacheHelper.RemoveCacheObject(CacheKeys.AllRoles); return result.Result == 1; } public async Task> GetAllRoles() { var cacheObject = _cacheHelper.GetCacheObject>(CacheKeys.AllRoles); if (cacheObject != null) return cacheObject; Task> roles = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => roles = connection.QueryAsync("SELECT Role FROM pf_Role ORDER BY Role")); _cacheHelper.SetCacheObject(CacheKeys.AllRoles, roles.Result); return roles.Result.ToList(); } public async Task> GetUserRoles(int userID) { var cacheObject = _cacheHelper.GetCacheObject>(CacheKeys.UserRole(userID)); if (cacheObject != null) return cacheObject; Task> roles = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => roles = connection.QueryAsync("SELECT Role FROM pf_PopForumsUserRole WHERE UserID = @UserID", new { UserID = userID })); var list = roles.Result.ToList(); _cacheHelper.SetCacheObject(CacheKeys.UserRole(userID), list); return list; } private async Task AddUserToRole(int userID, string role) { await RemoveUserFromRole(userID, role); await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_PopForumsUserRole (UserID, Role) VALUES (@UserID, @Role)", new { UserID = userID, Role = role })); _cacheHelper.RemoveCacheObject(CacheKeys.UserRole(userID)); } private async Task RemoveUserFromRole(int userID, string role) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_PopForumsUserRole WHERE UserID = @UserID AND Role = @Role", new { UserID = userID, Role = role })); _cacheHelper.RemoveCacheObject(CacheKeys.UserRole(userID)); } public async Task ReplaceUserRoles(int userID, string[] roles) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_PopForumsUserRole WHERE UserID = @UserID", new { UserID = userID })); foreach (var role in roles) await AddUserToRole(userID, role); } } ================================================ FILE: src/PopForums.Sql/Repositories/SearchIndexQueueRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class SearchIndexQueueRepository : ISearchIndexQueueRepository { private readonly ISqlObjectFactory _sqlObjectFactory; public SearchIndexQueueRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } public async Task Enqueue(SearchIndexPayload payload) { var serializedPayload = JsonSerializer.Serialize(payload); await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_SearchQueue (Payload) VALUES (@Payload)", new { Payload = serializedPayload })); } public async Task Dequeue() { var sql = @"WITH cte AS ( SELECT TOP(1) Payload FROM pf_SearchQueue WITH (ROWLOCK, READPAST) ORDER BY Id) DELETE FROM cte OUTPUT DELETED.Payload;"; Task serializedPayload = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => serializedPayload = connection.QuerySingleOrDefaultAsync(sql)); if (string.IsNullOrEmpty(serializedPayload.Result)) return new SearchIndexPayload(); var payload = JsonSerializer.Deserialize(serializedPayload.Result); return payload; } } ================================================ FILE: src/PopForums.Sql/Repositories/SearchRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class SearchRepository : ISearchRepository { public SearchRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } private readonly ISqlObjectFactory _sqlObjectFactory; public virtual async Task> GetJunkWords() { Task> words = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => words = connection.QueryAsync("SELECT JunkWord FROM pf_JunkWords ORDER BY JunkWord")); return words.Result.ToList(); } public virtual async Task CreateJunkWord(string word) { Task> exists = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => exists = connection.QueryAsync("SELECT JunkWord FROM pf_JunkWords WHERE JunkWord LIKE @JunkWord", new { JunkWord = word })); if (exists.Result.Any()) return; await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_JunkWords (JunkWord) VALUES (@JunkWord)", new { JunkWord = word })); } public virtual async Task DeleteJunkWord(string word) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_JunkWords WHERE JunkWord = @JunkWord", new { JunkWord = word })); } public virtual async Task DeleteAllIndexedWordsForTopic(int topicID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => { var command = new SqlCommand("DELETE FROM pf_TopicSearchWords WHERE TopicID = @TopicID", (SqlConnection)connection); command.CommandTimeout = 120; command.Parameters.Add(new SqlParameter("TopicID", topicID)); return command.ExecuteNonQueryAsync(); }); } public virtual async Task SaveSearchWord(int topicID, string word, int rank) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_TopicSearchWords (SearchWord, TopicID, Rank) VALUES (@SearchWord, @TopicID, @Rank)", new { SearchWord = word, TopicID = topicID, Rank = rank })); } public virtual async Task>, int>> SearchTopics(string searchTerm, List hiddenForums, SearchType searchType, int startRow, int pageSize) { var topicCount = 0; if (searchTerm.Trim() == string.Empty) return Tuple.Create(new Response>(new List()), topicCount); var topics = new List(); var wordArray = searchTerm.Split(new [] { ' ' }); var wordList = new List(); var junkWords = await GetJunkWords(); var alphaNum = SearchService.SearchWordPattern; for (var x = 0; x < wordArray.Length; x++) { foreach (Match match in alphaNum.Matches(wordArray[x])) { if (!junkWords.Contains(match.Value)) wordList.Add(match.Value); } } var words = wordList.ToArray(); var wordParameters = new Dictionary(); var sb = new StringBuilder(); sb.Append("WITH FirstEntries AS (SELECT ROW_NUMBER() OVER (PARTITION BY pf_Topic.TopicID ORDER BY pf_Topic.LastPostTime DESC) AS GroupRow, "); sb.Append(TopicRepository.TopicFields); sb.Append(", (("); for (var x = 0; x < words.Length; x++) { sb.Append("q"); sb.Append(x.ToString()); sb.Append(".Rank"); if (x < words.Length - 1) sb.Append("+"); } sb.Append(")/" + words.Length + ") AS CompositeRank FROM pf_Topic "); for (int x = 0; x < words.Length; x++) { string xstring = x.ToString(); sb.Append(" JOIN (SELECT TOP 10000 TopicID, pf_TopicSearchWords.Rank FROM pf_TopicSearchWords WHERE SearchWord = "); var param = "@w" + x; wordParameters.Add(param, words[x]); sb.Append(param); sb.Append(" ORDER BY pf_TopicSearchWords.Rank DESC) AS q"); sb.Append(xstring); sb.Append(" ON pf_Topic.TopicID = q"); sb.Append(xstring); sb.Append(".TopicID"); } sb.Append(" WHERE NOT pf_Topic.IsDeleted = 1"); if (hiddenForums.Count > 0) { sb.Append(" AND"); for (int x = 0; x < hiddenForums.Count; x++) { if (x > 0) sb.Append(" AND"); sb.Append($" NOT ForumID = {hiddenForums[x]} "); } } string orderBy; switch (searchType) { case SearchType.Date: orderBy = "LastPostTime DESC"; break; case SearchType.Name: orderBy = "StartedByName"; break; case SearchType.Replies: orderBy = "ReplyCount DESC"; break; case SearchType.Title: orderBy = "Title"; break; default: orderBy = "CompositeRank DESC, LastPostTime DESC"; break; } sb.Append("),\r\nEntries as (SELECT *,ROW_NUMBER() OVER (ORDER BY "); sb.Append(orderBy); sb.Append(") AS Row, COUNT(*) OVER () as cnt FROM FirstEntries WHERE GroupRow = 1)\r\nSELECT TopicID, ForumID, Title, ReplyCount, ViewCount, StartedByUserID, StartedByName, LastPostUserID, LastPostName, LastPostTime, IsClosed, IsPinned, IsDeleted, UrlName, AnswerPostID, cnt FROM Entries WHERE Row BETWEEN @StartRow AND @StartRow + @PageSize - 1"); if (words.Length == 0) return Tuple.Create(new Response>(new List()), topicCount); var connection = _sqlObjectFactory.GetConnection(); var command = connection.Command(_sqlObjectFactory, sb.ToString()); command.AddParameter(_sqlObjectFactory, "@StartRow", startRow); command.AddParameter(_sqlObjectFactory, "@PageSize", pageSize); foreach (var item in wordParameters) command.AddParameter(_sqlObjectFactory, item.Key, item.Value); connection.Open(); var reader = await command.ExecuteReaderAsync(); while (reader.Read()) { var topic = new Topic { TopicID = reader.GetInt32(0), ForumID = reader.GetInt32(1), Title = reader.GetString(2), ReplyCount = reader.GetInt32(3), ViewCount = reader.GetInt32(4), StartedByUserID = reader.GetInt32(5), StartedByName = reader.GetString(6), LastPostUserID = reader.GetInt32(7), LastPostName = reader.GetString(8), LastPostTime = reader.GetDateTime(9), IsClosed = reader.GetBoolean(10), IsPinned = reader.GetBoolean(11), IsDeleted = reader.GetBoolean(12), UrlName = reader.GetString(13), AnswerPostID = reader.NullIntDbHelper(14) }; topics.Add(topic); topicCount = Convert.ToInt32(reader["cnt"]); } reader.Dispose(); connection.Close(); // simple response since results are from database, not external service like ES or Azure return Tuple.Create(new Response>(topics), topicCount); } } ================================================ FILE: src/PopForums.Sql/Repositories/SecurityLogRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class SecurityLogRepository : ISecurityLogRepository { public SecurityLogRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } private readonly ISqlObjectFactory _sqlObjectFactory; public async Task Create(SecurityLogEntry logEntry) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_SecurityLog (SecurityLogType, UserID, TargetUserID, IP, Message, ActivityDate) VALUES (@SecurityLogType, @UserID, @TargetUserID, @IP, @Message, @ActivityDate)", new { logEntry.SecurityLogType, logEntry.UserID, logEntry.TargetUserID, logEntry.IP, logEntry.Message, logEntry.ActivityDate })); } public async Task> GetByUserID(int userID, DateTime startDate, DateTime endDate) { Task> list = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => list = connection.QueryAsync("SELECT SecurityLogID, SecurityLogType, UserID, TargetUserID, IP, Message, ActivityDate FROM pf_SecurityLog WHERE (UserID = @UserID OR TargetUserID = @UserID) AND ActivityDate >= @StartDate AND ActivityDate <= @EndDate ORDER BY ActivityDate", new { UserID = userID, StartDate = startDate, EndDate = endDate })); return list.Result.ToList(); } public async Task> GetIPHistory(string ip, DateTime start, DateTime end) { var list = new List(); await _sqlObjectFactory.GetConnection().UsingAsync(async connection => { var reader = await connection.ExecuteReaderAsync("SELECT SecurityLogID AS ID, ActivityDate AS EventTime, TargetUserID AS UserID, SecurityLogType, Message FROM pf_SecurityLog WHERE IP = @IP AND ActivityDate >= @Start AND ActivityDate <= @End ORDER BY ActivityDate", new {IP = ip, Start = start, End = end}); while (reader.Read()) { list.Add(new IPHistoryEvent { ID = reader.GetInt32(0), EventTime = reader.GetDateTime(1), UserID = reader.IsDBNull(2) ? (int?)null : reader.GetInt32(2), Name = string.Empty, Description = $"{((SecurityLogType) reader[3]).ToString()} - {(reader.IsDBNull(4) ? null : reader.GetString(4))}", Type = "SecurityLogEntry" }); } }); return list; } } ================================================ FILE: src/PopForums.Sql/Repositories/ServiceHeartbeatRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class ServiceHeartbeatRepository : IServiceHeartbeatRepository { public ServiceHeartbeatRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } private readonly ISqlObjectFactory _sqlObjectFactory; public async Task RecordHeartbeat(string serviceName, string machineName, DateTime lastRun) { await _sqlObjectFactory.GetConnection().UsingAsync(async connection => { await connection.ExecuteAsync("DELETE FROM pf_ServiceHeartbeat WHERE ServiceName = @ServiceName AND MachineName = @MachineName", new { ServiceName = serviceName, MachineName = machineName }); await connection.ExecuteAsync("INSERT INTO pf_ServiceHeartbeat (ServiceName, MachineName, LastRun) VALUES (@ServiceName, @MachineName, @LastRun)", new { ServiceName = serviceName, MachineName = machineName, LastRun = lastRun }); }); } public async Task> GetAll() { Task> list = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => list = connection.QueryAsync("SELECT ServiceName, MachineName, LastRun FROM pf_ServiceHeartbeat ORDER BY ServiceName")); return list.Result.ToList(); } public async Task ClearAll() { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_ServiceHeartbeat")); } } ================================================ FILE: src/PopForums.Sql/Repositories/SettingsRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class SettingsRepository : ISettingsRepository { public SettingsRepository(ISqlObjectFactory sqlObjectFactory, ICacheHelper cacheHelper) { _sqlObjectFactory = sqlObjectFactory; _cacheHelper = cacheHelper; _cacheHelper.OnRemoveCacheKey += key => { var effectiveCacheKey = _cacheHelper.GetEffectiveCacheKey(CacheKey); if (key == effectiveCacheKey) OnSettingsInvalidated?.Invoke(); }; } private readonly ISqlObjectFactory _sqlObjectFactory; private readonly ICacheHelper _cacheHelper; private const string CacheKey = "pf.settings"; public event Action OnSettingsInvalidated; public Dictionary Get() { var cached = _cacheHelper.GetCacheObject>(CacheKey); if (cached != null) return cached; var dictionary = new Dictionary(); _sqlObjectFactory.GetConnection().Using(connection => dictionary = connection.Query("SELECT Setting, [Value] FROM pf_Setting").ToDictionary(r => (string)r.Setting, r => (string)r.Value)); _cacheHelper.SetCacheObject(CacheKey, dictionary); return dictionary; } public void Save(Dictionary dictionary) { _sqlObjectFactory.GetConnection().Using(connection => { connection.Execute("DELETE FROM pf_Setting"); foreach (var key in dictionary) connection.Execute("INSERT INTO pf_Setting (Setting, [Value]) VALUES (@Setting, @Value)", new { Setting = key.Key, Value = key.Value == null ? string.Empty : key.Value.ToString()}); }); _cacheHelper.RemoveCacheObject(CacheKey); } } ================================================ FILE: src/PopForums.Sql/Repositories/SetupRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class SetupRepository : ISetupRepository { public SetupRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } private readonly ISqlObjectFactory _sqlObjectFactory; public bool IsConnectionPossible() { try { var connection = _sqlObjectFactory.GetConnection(); connection.Open(); connection.Close(); return true; } catch { return false; } } public virtual bool IsDatabaseSetup() { const string sql = @"IF (NOT EXISTS (SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'pf_PopForumsUser')) BEGIN SELECT 0 END ELSE BEGIN SELECT 1 END"; var result = false; _sqlObjectFactory.GetConnection().Using(connection => result = connection.ExecuteScalar(sql)); return result; } public void SetupDatabase() { var assembly = typeof(SetupRepository).GetTypeInfo().Assembly; var stream = assembly.GetManifestResourceStream("PopForums.Sql.PopForums.sql"); var reader = new StreamReader(stream); var sql = reader.ReadToEnd(); using var connection = _sqlObjectFactory.GetConnection() as SqlConnection; using var command = new SqlCommand(sql, connection); connection.Open(); command.ExecuteNonQuery(); connection.Close(); } } ================================================ FILE: src/PopForums.Sql/Repositories/SubscribeNotificationRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class SubscribeNotificationRepository : ISubscribeNotificationRepository { private readonly ISqlObjectFactory _sqlObjectFactory; public SubscribeNotificationRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } public async Task Enqueue(SubscribeNotificationPayload payload) { var serializedPayload = JsonSerializer.Serialize(payload); await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_SubNotifyQueue (Payload) VALUES (@Payload)", new { Payload = serializedPayload })); } public async Task Dequeue() { Task serializedPayload = null; var sql = @"WITH cte AS ( SELECT TOP(1) Payload FROM pf_SubNotifyQueue WITH (ROWLOCK, READPAST) ORDER BY Id) DELETE FROM cte OUTPUT DELETED.Payload;"; await _sqlObjectFactory.GetConnection().UsingAsync(connection => serializedPayload = connection.QuerySingleOrDefaultAsync(sql)); if (string.IsNullOrEmpty(serializedPayload.Result)) return null; var payload = JsonSerializer.Deserialize(serializedPayload.Result); return payload; } } ================================================ FILE: src/PopForums.Sql/Repositories/SubscribedTopicsRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class SubscribedTopicsRepository : ISubscribedTopicsRepository { public SubscribedTopicsRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } private readonly ISqlObjectFactory _sqlObjectFactory; public async Task> GetSubscribedTopics(int userID, int startRow, int pageSize) { Task> list = null; const string sql = @" DECLARE @Counter int SET @Counter = (@StartRow + @PageSize - 1) SET ROWCOUNT @Counter; WITH Entries AS ( SELECT ROW_NUMBER() OVER (ORDER BY IsPinned DESC, LastPostTime DESC) AS Row, pf_Topic.TopicID, pf_Topic.ForumID, pf_Topic.Title, pf_Topic.ReplyCount, pf_Topic.ViewCount, pf_Topic.StartedByUserID, pf_Topic.StartedByName, pf_Topic.LastPostUserID, pf_Topic.LastPostName, pf_Topic.LastPostTime, pf_Topic.IsClosed, pf_Topic.IsPinned, pf_Topic.IsDeleted, pf_Topic.UrlName, pf_Topic.AnswerPostID FROM pf_Topic JOIN pf_SubscribeTopic S ON pf_Topic.TopicID = S.TopicID WHERE S.UserID = @UserID AND pf_Topic.IsDeleted = 0) SELECT TopicID, ForumID, Title, ReplyCount, ViewCount, StartedByUserID, StartedByName, LastPostUserID, LastPostName, LastPostTime, IsClosed, IsPinned, IsDeleted, UrlName, AnswerPostID FROM Entries WHERE Row between @StartRow and @StartRow + @PageSize - 1 SET ROWCOUNT 0"; await _sqlObjectFactory.GetConnection().UsingAsync(connection => list = connection.QueryAsync(sql, new { UserID = userID, StartRow = startRow, PageSize = pageSize})); return list.Result.ToList(); } public async Task GetSubscribedTopicCount(int userID) { Task count = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => count = connection.ExecuteScalarAsync("SELECT COUNT(*) FROM pf_SubscribeTopic S JOIN pf_Topic T ON S.TopicID = T.TopicID WHERE S.UserID = @UserID AND T.IsDeleted = 0", new { UserID = userID })); return await count; } public async Task> GetSubscribedUserIDs(int topicID) { Task> list = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => list = connection.QueryAsync("SELECT pf_SubscribeTopic.UserID FROM pf_PopForumsUser JOIN pf_SubscribeTopic ON pf_PopForumsUser.UserID = pf_SubscribeTopic.UserID WHERE TopicID = @TopicID", new { TopicID = topicID })); return list.Result.ToList(); } public async Task IsTopicSubscribed(int userID, int topicID) { Task> result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.QueryAsync("SELECT * FROM pf_SubscribeTopic WHERE UserID = @UserID AND TopicID = @TopicID", new { UserID = userID, TopicID = topicID })); return result.Result.Any(); } public async Task AddSubscribedTopic(int userID, int topicID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_SubscribeTopic (UserID, TopicID) VALUES (@UserID, @TopicID)", new { UserID = userID, TopicID = topicID })); } public async Task RemoveSubscribedTopic(int userID, int topicID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_SubscribeTopic WHERE UserID = @UserID AND TopicID = @TopicID", new { UserID = userID, TopicID = topicID })); } } ================================================ FILE: src/PopForums.Sql/Repositories/TopicRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class TopicRepository : ITopicRepository { public TopicRepository(ISqlObjectFactory sqlObjectFactory, ICacheHelper cache) { _sqlObjectFactory = sqlObjectFactory; _cache = cache; } private readonly ISqlObjectFactory _sqlObjectFactory; private readonly ICacheHelper _cache; internal const string TopicFields = "pf_Topic.TopicID, pf_Topic.ForumID, pf_Topic.Title, pf_Topic.ReplyCount, pf_Topic.ViewCount, pf_Topic.StartedByUserID, pf_Topic.StartedByName, pf_Topic.LastPostUserID, pf_Topic.LastPostName, pf_Topic.LastPostTime, pf_Topic.IsClosed, pf_Topic.IsPinned, pf_Topic.IsDeleted, pf_Topic.UrlName, pf_Topic.AnswerPostID"; public async Task GetLastUpdatedTopic(int forumID) { Task topic = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => topic = connection.QuerySingleOrDefaultAsync("SELECT TOP 1 " + TopicFields + @" FROM pf_Topic WHERE pf_Topic.ForumID = @ForumID AND pf_Topic.IsDeleted = 0 ORDER BY pf_Topic.LastPostTime DESC", new { ForumID = forumID })); return await topic; } public async Task GetTopicCount(int forumID, bool includeDelete) { var sql = "SELECT COUNT(*) FROM pf_Topic WHERE ForumID = @ForumID"; if (!includeDelete) sql += " AND IsDeleted = 0"; Task count = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => count = connection.ExecuteScalarAsync(sql, new { ForumID = forumID })); return await count; } public async Task GetTopicCountByUser(int userID, bool includeDeleted, List excludedForums) { var sql = "SELECT COUNT(DISTINCT pf_Topic.TopicID) FROM pf_Topic JOIN pf_Post ON pf_Topic.TopicID = pf_Post.TopicID WHERE pf_Post.UserID = @UserID"; if (!includeDeleted) sql += " AND pf_Topic.IsDeleted = 0 AND pf_Post.IsDeleted = 0"; sql = GenerateExcludedForumSql(sql, excludedForums); Task count = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => count = connection.ExecuteScalarAsync(sql, new { UserID = userID })); return await count; } public async Task GetTopicCount(bool includeDeleted, List excludedForums) { var sql = "SELECT COUNT(*) FROM pf_Topic"; if (!includeDeleted) sql += " WHERE IsDeleted = 0"; else sql += " WHERE 1 = 1"; sql = GenerateExcludedForumSql(sql, excludedForums); Task count = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => count = connection.ExecuteScalarAsync(sql)); return await count; } private static string GenerateExcludedForumSql(string sql, List excludedForums) { if (excludedForums.Count > 0) { sql += " AND ForumID NOT IN ("; for (var i = 0; i < excludedForums.Count; i++) { sql += excludedForums[i]; if (i != excludedForums.Count - 1) sql += ","; } sql += ")"; } return sql; } public async Task GetPostCount(int forumID, bool includeDelete) { var sql = "SELECT COUNT(pf_Post.TopicID) FROM pf_Post JOIN pf_Topic ON pf_Post.TopicID = pf_Topic.TopicID WHERE pf_Topic.ForumID = @ForumID"; if (!includeDelete) sql += " AND pf_Post.IsDeleted = 0 AND pf_Topic.IsDeleted = 0"; Task count = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => count = connection.ExecuteScalarAsync(sql, new { ForumID = forumID })); return await count; } public async Task Get(int topicID) { Task topic = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => topic = connection.QuerySingleOrDefaultAsync("SELECT " + TopicFields + " FROM pf_Topic WHERE TopicID = @TopicID", new { TopicID = topicID })); return await topic; } public async Task Get(string urlName) { Task topic = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => topic = connection.QuerySingleOrDefaultAsync("SELECT " + TopicFields + " FROM pf_Topic WHERE UrlName = @UrlName", new { UrlName = urlName })); return await topic; } public async Task> Get(int forumID, bool includeDeleted, int startRow, int pageSize) { const string sql = @" DECLARE @Counter int SET @Counter = (@StartRow + @PageSize - 1) SET ROWCOUNT @Counter; WITH Entries AS ( SELECT ROW_NUMBER() OVER (ORDER BY IsPinned DESC, LastPostTime DESC) AS Row, pf_Topic.TopicID, pf_Topic.ForumID, pf_Topic.Title, pf_Topic.ReplyCount, pf_Topic.ViewCount, pf_Topic.StartedByUserID, pf_Topic.StartedByName, pf_Topic.LastPostUserID, pf_Topic.LastPostName, pf_Topic.LastPostTime, pf_Topic.IsClosed, pf_Topic.IsPinned, pf_Topic.IsDeleted, pf_Topic.UrlName, pf_Topic.AnswerPostID FROM pf_Topic WHERE ForumID = @ForumID AND ((@IncludeDeleted = 1) OR (@IncludeDeleted = 0 AND IsDeleted = 0))) SELECT TopicID, ForumID, Title, ReplyCount, ViewCount, StartedByUserID, StartedByName, LastPostUserID, LastPostName, LastPostTime, IsClosed, IsPinned, IsDeleted, UrlName, AnswerPostID FROM Entries WHERE Row between @StartRow and @StartRow + @PageSize - 1 SET ROWCOUNT 0"; Task> topics = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => topics = connection.QueryAsync(sql, new { ForumID = forumID, IncludeDeleted = includeDeleted, StartRow = startRow, PageSize = pageSize })); return topics.Result.ToList(); } public async Task> GetTopicsByUser(int userID, bool includeDeleted, List excludedForums, int startRow, int pageSize) { var sql = @" DECLARE @Counter int SET @Counter = (@StartRow + @PageSize - 1) SET ROWCOUNT @Counter; WITH FirstEntries AS ( SELECT ROW_NUMBER() OVER (PARTITION BY pf_Topic.TopicID ORDER BY IsPinned DESC, LastPostTime DESC) AS GroupRow, pf_Topic.TopicID, pf_Topic.ForumID, pf_Topic.Title, pf_Topic.ReplyCount, pf_Topic.ViewCount, pf_Topic.StartedByUserID, pf_Topic.StartedByName, pf_Topic.LastPostUserID, pf_Topic.LastPostName, pf_Topic.LastPostTime, pf_Topic.IsClosed, pf_Topic.IsPinned, pf_Topic.IsDeleted, pf_Topic.UrlName, pf_Topic.AnswerPostID FROM pf_Topic JOIN pf_Post ON pf_Topic.TopicID = pf_Post.TopicID WHERE pf_Post.UserID = @UserID AND ((@IncludeDeleted = 1) OR (@IncludeDeleted = 0 AND pf_Topic.IsDeleted = 0))"; sql = GenerateExcludedForumSql(sql, excludedForums); sql += @"), Entries as ( SELECT *,ROW_NUMBER() OVER (ORDER BY IsPinned DESC, LastPostTime DESC) AS Row FROM FirstEntries WHERE GroupRow = 1) SELECT TopicID, Entries.ForumID, Entries.Title, ReplyCount, ViewCount, StartedByUserID, StartedByName, LastPostUserID, Entries.LastPostName, Entries.LastPostTime, IsClosed, IsPinned, IsDeleted, UrlName, AnswerPostID FROM Entries WHERE Row between @StartRow and @StartRow + @PageSize - 1 SET ROWCOUNT 0"; Task> topics = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => topics = connection.QueryAsync(sql, new { UserID = userID, IncludeDeleted = includeDeleted, StartRow = startRow, PageSize = pageSize })); return topics.Result.ToList(); } public async Task> Get(bool includeDeleted, List excludedForums, int startRow, int pageSize) { var sql = @" DECLARE @Counter int SET @Counter = (@StartRow + @PageSize - 1) SET ROWCOUNT @Counter; WITH Entries AS ( SELECT ROW_NUMBER() OVER (ORDER BY LastPostTime DESC) AS Row, pf_Topic.TopicID, pf_Topic.ForumID, pf_Topic.Title, pf_Topic.ReplyCount, pf_Topic.ViewCount, pf_Topic.StartedByUserID, pf_Topic.StartedByName, pf_Topic.LastPostUserID, pf_Topic.LastPostName, pf_Topic.LastPostTime, pf_Topic.IsClosed, pf_Topic.IsPinned, pf_Topic.IsDeleted, pf_Topic.UrlName, pf_Topic.AnswerPostID FROM pf_Topic WHERE ((@IncludeDeleted = 1) OR (@IncludeDeleted = 0 AND IsDeleted = 0))"; sql = GenerateExcludedForumSql(sql, excludedForums); sql += @") SELECT TopicID, ForumID, Title, ReplyCount, ViewCount, StartedByUserID, StartedByName, LastPostUserID, LastPostName, LastPostTime, IsClosed, IsPinned, IsDeleted, UrlName, AnswerPostID FROM Entries WHERE Row between @StartRow and @StartRow + @PageSize - 1 SET ROWCOUNT 0"; Task> topics = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => topics = connection.QueryAsync(sql, new { IncludeDeleted = includeDeleted, StartRow = startRow, PageSize = pageSize })); return topics.Result.ToList(); } public async Task> Get(IEnumerable topicIDs) { var list = topicIDs.ToList(); if (list.Count == 0) return new List(); var ids = string.Join(",", list); var sql = $@"SELECT {TopicFields} FROM pf_Topic WHERE TopicID IN ({ids})"; var count = list.Count; if (count > 1) { var x = 1; var orderBy = @" ORDER BY CASE"; foreach (var topicID in list.Take(count - 1)) { orderBy += $@" WHEN TopicID = {topicID} THEN {x}"; x++; } orderBy += $@" ELSE {x} END"; sql += orderBy; } Task> topics = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => topics = connection.QueryAsync(sql)); return topics.Result.ToList(); } public async Task> Get(int forumID, bool includeDeleted, List excludedForums) { var sql = @" SELECT pf_Topic.TopicID, pf_Topic.ForumID, pf_Topic.Title, pf_Topic.ReplyCount, pf_Topic.ViewCount, pf_Topic.StartedByUserID, pf_Topic.StartedByName, pf_Topic.LastPostUserID, pf_Topic.LastPostName, pf_Topic.LastPostTime, pf_Topic.IsClosed, pf_Topic.IsPinned, pf_Topic.IsDeleted, pf_Topic.UrlName, pf_Topic.AnswerPostID FROM pf_Topic WHERE ForumID = @ForumID AND ((@IncludeDeleted = 1) OR (@IncludeDeleted = 0 AND IsDeleted = 0))"; sql = GenerateExcludedForumSql(sql, excludedForums); Task> topics = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => topics = connection.QueryAsync(sql, new { ForumID = forumID, IncludeDeleted = includeDeleted })); return topics.Result.ToList(); } public async Task> GetUrlNamesThatStartWith(string urlName) { Task> list = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => list = connection.QueryAsync("SELECT UrlName FROM pf_Topic WHERE UrlName LIKE @UrlName + '%'", new { UrlName = urlName })); return list.Result.ToList(); } public virtual async Task Create(int forumID, string title, int replyCount, int viewCount, int startedByUserID, string startedByName, int lastPostUserID, string lastPostName, DateTime lastPostTime, bool isClosed, bool isPinned, bool isDeleted, string urlName) { Task result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.QuerySingleAsync("INSERT INTO pf_Topic (ForumID, Title, ReplyCount, ViewCount, StartedByUserID, StartedByName, LastPostUserID, LastPostName, LastPostTime, IsClosed, IsPinned, IsDeleted, UrlName) VALUES (@ForumID, @Title, @ReplyCount, @ViewCount, @StartedByUserID, @StartedByName, @LastPostUserID, @LastPostName, @LastPostTime, @IsClosed, @IsPinned, @IsDeleted, @UrlName);SELECT CAST(SCOPE_IDENTITY() as int)", new { ForumID = forumID, Title = title, ReplyCount = replyCount, ViewCount = viewCount, StartedByUserID = startedByUserID, StartedByName = startedByName, LastPostUserID = lastPostUserID, LastPostName = lastPostName, LastPostTime = lastPostTime, IsClosed = isClosed, IsPinned = isPinned, IsDeleted = isDeleted, UrlName = urlName })); return await result; } public async Task IncrementReplyCount(int topicID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_Topic SET ReplyCount = ReplyCount + 1 WHERE TopicID = @TopicID", new { TopicID = topicID })); } public async Task IncrementViewCount(int topicID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_Topic SET ViewCount = ViewCount + 1 WHERE TopicID = @TopicID", new { TopicID = topicID })); } public async Task UpdateLastTimeAndUser(int topicID, int userID, string name, DateTime postTime) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_Topic SET LastPostUserID = @LastPostUserID, LastPostName = @LastPostName, LastPostTime = @LastPostTime WHERE TopicID = @TopicID", new { LastPostUserID = userID, LastPostName = name, LastPostTime = postTime, TopicID = topicID })); } public async Task CloseTopic(int topicID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_Topic SET IsClosed = 1 WHERE TopicID = @TopicID", new { TopicID = topicID })); } public async Task OpenTopic(int topicID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_Topic SET IsClosed = 0 WHERE TopicID = @TopicID", new { TopicID = topicID })); } public async Task PinTopic(int topicID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_Topic SET IsPinned = 1 WHERE TopicID = @TopicID", new { TopicID = topicID })); } public async Task UnpinTopic(int topicID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_Topic SET IsPinned = 0 WHERE TopicID = @TopicID", new { TopicID = topicID })); } public async Task DeleteTopic(int topicID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_Topic SET IsDeleted = 1 WHERE TopicID = @TopicID", new { TopicID = topicID })); var key = string.Format(PostRepository.CacheKeys.PostPages, topicID); _cache.RemoveCacheObject(key); } public async Task UndeleteTopic(int topicID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_Topic SET IsDeleted = 0 WHERE TopicID = @TopicID", new { TopicID = topicID })); } public async Task HardDeleteTopic(int topicID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_Topic WHERE TopicID = @TopicID", new { TopicID = topicID })); var key = string.Format(PostRepository.CacheKeys.PostPages, topicID); _cache.RemoveCacheObject(key); } public async Task UpdateTitleAndForum(int topicID, int forumID, string newTitle, string newUrlName) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_Topic SET ForumID = @ForumID, Title = @Title, UrlName = @UrlName WHERE TopicID = @TopicID" ,new { ForumID = forumID, Title = newTitle, TopicID = topicID, UrlName = newUrlName})); } public async Task UpdateReplyCount(int topicID, int replyCount) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_Topic SET ReplyCount = @ReplyCount WHERE TopicID = @TopicID", new { ReplyCount = replyCount, TopicID = topicID })); } public async Task GetLastPostTime(int topicID) { Task result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.ExecuteScalarAsync("SELECT LastPostTime FROM pf_Topic WHERE TopicID = @TopicID", new { TopicID = topicID })); return await result; } public async Task UpdateAnswerPostID(int topicID, int? postID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_Topic SET AnswerPostID = @AnswerPostID WHERE TopicID = @TopicID", new { AnswerPostID = postID, TopicID = topicID })); } public async Task> CloseTopicsOlderThan(DateTime cutoffDate) { const string sql = @"DECLARE @ids TABLE (id int) UPDATE pf_Topic SET IsClosed = 1 OUTPUT INSERTED.TopicID WHERE LastPostTime < @CutoffDate AND IsClosed = 0 SELECT id FROM @ids"; Task> list = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => list = connection.QueryAsync(sql, new { CutoffDate = cutoffDate })); return await list; } public async Task>> GetUrlNames(bool includeDeleted, List excludedForums, int startRow, int pageSize) { var sql = @" DECLARE @Counter int SET @Counter = (@StartRow + @PageSize - 1) SET ROWCOUNT @Counter; WITH Entries AS ( SELECT ROW_NUMBER() OVER (ORDER BY LastPostTime DESC) AS Row, pf_Topic.UrlName, pf_Topic.LastPostTime FROM pf_Topic WHERE ((@IncludeDeleted = 1) OR (@IncludeDeleted = 0 AND IsDeleted = 0))"; sql = GenerateExcludedForumSql(sql, excludedForums); sql += @") SELECT UrlName AS Item1, LastPostTime AS Item2 FROM Entries WHERE Row between @StartRow and @StartRow + @PageSize - 1 SET ROWCOUNT 0"; Task>> topics = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => topics = connection.QueryAsync>(sql, new { IncludeDeleted = includeDeleted, StartRow = startRow, PageSize = pageSize })); return topics.Result.ToList(); } } ================================================ FILE: src/PopForums.Sql/Repositories/TopicViewLogRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class TopicViewLogRepository : ITopicViewLogRepository { private readonly ISqlObjectFactory _sqlObjectFactory; public TopicViewLogRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } public async Task Log(int? userID, int topicID, DateTime timeStamp) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_TopicViewLog (UserID, TopicID, [TimeStamp]) VALUES (@UserID, @TopicID, @TimeStamp)", new {UserID = userID, TopicID = topicID, TimeStamp = timeStamp})); } } ================================================ FILE: src/PopForums.Sql/Repositories/UserAvatarRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class UserAvatarRepository : IUserAvatarRepository { public UserAvatarRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } private readonly ISqlObjectFactory _sqlObjectFactory; [Obsolete("Use GetImageStream(int) instead.")] public async Task GetImageData(int userAvatarID) { Task data = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => data = connection.ExecuteScalarAsync("SELECT ImageData FROM pf_UserAvatar WHERE UserAvatarID = @UserAvatarID", new { UserAvatarID = userAvatarID })); return await data; } public async Task GetImageStream(int userAvatarID) { var connection = (SqlConnection)_sqlObjectFactory.GetConnection(); var command = new SqlCommand("SELECT ImageData FROM pf_UserAvatar WHERE UserAvatarID = @UserAvatarID", connection); command.Parameters.AddWithValue("@UserAvatarID", userAvatarID); connection.Open(); var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess); if (await reader.ReadAsync() && !await reader.IsDBNullAsync(0)) { var stream = reader.GetStream(0); var streamResponse = new StreamResponse(stream, connection, reader); return streamResponse; } return default; } public async Task> GetUserAvatarIDs(int userID) { Task> ids = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => ids = connection.QueryAsync("SELECT UserAvatarID FROM pf_UserAvatar WHERE UserID = @UserID", new {UserID = userID})); return ids.Result.ToList(); } public async Task SaveNewAvatar(int userID, byte[] imageData, DateTime timeStamp) { Task avatarID = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => avatarID = connection.QuerySingleAsync("INSERT INTO pf_UserAvatar (UserID, ImageData, [TimeStamp]) VALUES (@UserID, @ImageData, @TimeStamp);SELECT CAST(SCOPE_IDENTITY() as int)", new { UserID = userID, TimeStamp = timeStamp, ImageData = imageData })); return await avatarID; } public async Task DeleteAvatarsByUserID(int userID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_UserAvatar WHERE UserID = @UserID", new { UserID = userID })); } public async Task GetLastModificationDate(int userAvatarID) { Task timeStamp = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => timeStamp = connection.QuerySingleOrDefaultAsync("SELECT [TimeStamp] FROM pf_UserAvatar WHERE UserAvatarID = @UserAvatarID", new { UserAvatarID = userAvatarID })); return await timeStamp; } } ================================================ FILE: src/PopForums.Sql/Repositories/UserAwardRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class UserAwardRepository : IUserAwardRepository { public UserAwardRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } private readonly ISqlObjectFactory _sqlObjectFactory; public virtual async Task IssueAward(int userID, string awardDefinitionID, string title, string description, DateTime timeStamp) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync(@"MERGE pf_UserAward WITH (HOLDLOCK) AS target USING (SELECT @UserID, @AwardDefinitionID) AS source (UserID, AwardDefinitionID) ON (target.UserID = source.UserID AND target.AwardDefinitionID = source.AwardDefinitionID) WHEN NOT MATCHED THEN INSERT (UserID, AwardDefinitionID, Title, Description, TimeStamp) VALUES (@UserID, @AwardDefinitionID, @Title, @Description, @TimeStamp);", new { UserID = userID, AwardDefinitionID = awardDefinitionID, Title = title, Description = description, TimeStamp = timeStamp })); } public async Task IsAwarded(int userID, string awardDefinitionID) { Task count = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => count = connection.ExecuteScalarAsync("SELECT COUNT(*) FROM pf_UserAward WHERE UserID = @UserID AND AwardDefinitionID = @AwardDefinitionID", new { UserID = userID, AwardDefinitionID = awardDefinitionID })); return count.Result > 0; } public async Task> GetAwards(int userID) { Task> list = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => list = connection.QueryAsync("SELECT UserAwardID, UserID, AwardDefinitionID, Title, Description, TimeStamp FROM pf_UserAward WHERE UserID = @UserID", new { UserID = userID })); return list.Result.ToList(); } } ================================================ FILE: src/PopForums.Sql/Repositories/UserImageRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class UserImageRepository : IUserImageRepository { public UserImageRepository(ISqlObjectFactory sqlObjectFactory) { _sqlObjectFactory = sqlObjectFactory; } private readonly ISqlObjectFactory _sqlObjectFactory; [Obsolete("Use GetImageStream(int) instead.")] public async Task GetImageData(int userImageID) { Task data = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => data = connection.ExecuteScalarAsync("SELECT ImageData FROM pf_UserImages WHERE UserImageID = @UserImageID", new { UserImageID = userImageID })); return await data; } public async Task GetImageStream(int userImageID) { var connection = (SqlConnection)_sqlObjectFactory.GetConnection(); var command = new SqlCommand("SELECT ImageData FROM pf_UserImages WHERE UserImageID = @UserImageID", connection); command.Parameters.AddWithValue("UserImageID", userImageID); connection.Open(); var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess); if (await reader.ReadAsync() && !await reader.IsDBNullAsync(0)) { var stream = reader.GetStream(0); var streamResponse = new StreamResponse(stream, connection, reader); return streamResponse; } return default; } public async Task> GetUserImages(int userID) { Task> list = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => list = connection.QueryAsync("SELECT UserImageID, UserID, SortOrder, IsApproved FROM pf_UserImages WHERE UserID = @UserID ORDER BY SortOrder", new { UserID = userID })); return list.Result.ToList(); } public async Task SaveNewImage(int userID, int sortOrder, bool isApproved, byte[] imageData, DateTime timeStamp) { Task userImageID = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => userImageID = connection.QuerySingleAsync("INSERT INTO pf_UserImages (UserID, SortOrder, IsApproved, [TimeStamp], ImageData) VALUES (@UserID, @SortOrder, @IsApproved, @TimeStamp, @ImageData);SELECT CAST(SCOPE_IDENTITY() as int)", new { UserID = userID, SortOrder = sortOrder, IsApproved = isApproved, TimeStamp = timeStamp, ImageData = imageData })); return await userImageID; } public async Task DeleteImagesByUserID(int userID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_UserImages WHERE UserID = @UserID", new { UserID = userID })); } public async Task GetLastModificationDate(int userImageID) { Task timeStamp = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => timeStamp = connection.QuerySingleOrDefaultAsync("SELECT [TimeStamp] FROM pf_UserImages WHERE UserImageID = @UserImageID", new { UserImageID = userImageID })); return await timeStamp; } public async Task> GetUnapprovedUserImages() { Task> list = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => list = connection.QueryAsync("SELECT UserImageID, UserID, SortOrder, IsApproved FROM pf_UserImages WHERE IsApproved = 0")); return list.Result.ToList(); } public async Task IsUserImageApproved(int userImageID) { Task result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.QuerySingleOrDefaultAsync("SELECT IsApproved FROM pf_UserImages WHERE UserImageID = @UserImageID", new { UserImageID = userImageID })); return await result; } public async Task ApproveUserImage(int userImageID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_UserImages SET IsApproved = 1 WHERE UserImageID = @UserImageID", new { UserImageID = userImageID })); } public async Task DeleteUserImage(int userImageID) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_UserImages WHERE UserImageID = @UserImageID", new { UserImageID = userImageID })); } public async Task Get(int userImageID) { Task userImage = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => userImage = connection.QuerySingleOrDefaultAsync("SELECT UserImageID, UserID, SortOrder, IsApproved FROM pf_UserImages WHERE UserImageID = @UserImageID", new { UserImageID = userImageID })); return await userImage; } } ================================================ FILE: src/PopForums.Sql/Repositories/UserRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class UserRepository : IUserRepository { public UserRepository(ICacheHelper cacheHelper, ISqlObjectFactory sqlObjectFactory) { _cacheHelper = cacheHelper; _sqlObjectFactory = sqlObjectFactory; } private readonly ICacheHelper _cacheHelper; private readonly ISqlObjectFactory _sqlObjectFactory; public class CacheKeys { public const string UsersOnline = "PopForums.Users.Online"; public const string TotalUsers = "PopForums.Users.Total"; public const string PointTotals = "PopForums.Users.Points."; } public const string PopForumsUserColumns = "pf_PopForumsUser.UserID, pf_PopForumsUser.Name, pf_PopForumsUser.Email, pf_PopForumsUser.CreationDate, pf_PopForumsUser.IsApproved, pf_PopForumsUser.AuthorizationKey, pf_PopForumsUser.TokenExpiration"; private void CacheUser(User user) { // We only cache users by name because it's the only consistent and repetitive get, looked up via the identity principal. var key = "PopForums.User." + user.Name; _cacheHelper.SetCacheObject(key, user); } private void RemoveCacheUser(string name) { var key = "PopForums.User." + name; _cacheHelper.RemoveCacheObject(key); } private User GetCachedUserByName(string name) { var key = "PopForums.User." + name; return _cacheHelper.GetCacheObject(key); } public async Task SetHashedPassword(User user, string hashedPassword, Guid salt) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_PopForumsUser SET Password = @Password, Salt = @Salt WHERE UserID = @UserID", new { Password = hashedPassword, Salt = salt, user.UserID })); } public async Task> GetHashedPasswordByEmail(string email) { string hashedPassword = null; Guid? saltCheck = null; await _sqlObjectFactory.GetConnection().UsingAsync(async connection => { var reader = await connection.ExecuteReaderAsync("SELECT Password, Salt FROM pf_PopForumsUser WHERE Email = @Email", new {Email = email}); if (reader.Read()) { hashedPassword = reader.GetString(0); saltCheck = reader.NullGuidDbHelper(1); } }); return Tuple.Create(hashedPassword, saltCheck); } public async Task> GetUsersFromIDs(IList ids) { Task> list = null; if (!ids.Any()) return new List(); var sql = "SELECT " + PopForumsUserColumns + " FROM pf_PopForumsUser WHERE UserID IN (" + ids[0]; foreach (var id in ids.Skip(1)) sql += ", " + id; sql += ")"; await _sqlObjectFactory.GetConnection().UsingAsync(connection => list = connection.QueryAsync(sql)); return list.Result.ToList(); } public async Task GetTotalUsers() { var cacheObject = _cacheHelper.GetCacheObject(CacheKeys.TotalUsers); if (cacheObject.HasValue) return cacheObject.Value; Task count = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => count = connection.ExecuteScalarAsync("SELECT COUNT(UserID) FROM pf_PopForumsUser")); _cacheHelper.SetCacheObject(CacheKeys.TotalUsers, count.Result); return await count; } private async Task GetUser(string sql, object parameters) { Task user = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => user = connection.QuerySingleOrDefaultAsync(sql, parameters)); return await user; } public async Task GetUser(int userID) { return await GetUser("SELECT " + PopForumsUserColumns + " FROM pf_PopForumsUser WHERE UserID = @UserID", new { UserID = userID }); } public async Task GetUserByName(string name) { var cachedUser = GetCachedUserByName(name); if (cachedUser != null) return cachedUser; var user = await GetUser("SELECT " + PopForumsUserColumns + " FROM pf_PopForumsUser WHERE Name = @Name", new { Name = name }); if (user == null) return null; CacheUser(user); return user; } public async Task GetUserByEmail(string email) { return await GetUser("SELECT " + PopForumsUserColumns + " FROM pf_PopForumsUser WHERE Email = @Email", new { Email = email }); } public async Task GetUserByAuthorizationKey(Guid key) { return await GetUser("SELECT " + PopForumsUserColumns + " FROM pf_PopForumsUser WHERE AuthorizationKey = @AuthorizationKey", new { AuthorizationKey = key }); } public virtual async Task CreateUser(string name, string email, DateTime creationDate, bool isApproved, string hashedPassword, Guid authorizationKey, Guid salt) { Task userID = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => userID = connection.QuerySingleAsync("INSERT INTO pf_PopForumsUser (Name, Email, CreationDate, IsApproved, AuthorizationKey, Password, Salt) VALUES (@Name, @Email, @CreationDate, @IsApproved, @AuthorizationKey, @Password, @Salt);SELECT CAST(SCOPE_IDENTITY() as int)", new { Name = name, Email = email, CreationDate = creationDate, IsApproved = isApproved, AuthorizationKey = authorizationKey, Password = hashedPassword, Salt = salt })); await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_UserActivity (UserID, LastActivityDate, LastLoginDate) VALUES (@UserID, @LastActivityDate, @LastLoginDate)", new {UserID = userID.Result, LastActivityDate = creationDate, LastLoginDate = creationDate})); return new User {UserID = await userID, Name = name, Email = email, CreationDate = creationDate, IsApproved = isApproved, AuthorizationKey = authorizationKey}; } public async Task UpdateLastActivityDate(User user, DateTime newDate) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_UserActivity SET LastActivityDate = @LastActivityDate WHERE UserID = @UserID", new { LastActivityDate = newDate, user.UserID })); } public async Task UpdateLastLoginDate(User user, DateTime newDate) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_UserActivity SET LastLoginDate = @LastLoginDate WHERE UserID = @UserID", new { LastLoginDate = newDate, user.UserID })); } public async Task UpdateRefreshToken(User user, string refreshToken) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_UserActivity SET RefreshToken = @refreshToken WHERE UserID = @UserID", new { refreshToken, user.UserID })); } public async Task GetRefreshToken(User user) { Task refreshToken = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => refreshToken = connection.QuerySingleOrDefaultAsync("SELECT RefreshToken FROM pf_UserActivity WHERE UserID = @UserID", new { user.UserID })); return refreshToken.Result; } public async Task ChangeName(User user, string newName) { var oldName = user.Name; await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_PopForumsUser SET Name = @Name WHERE UserID = @UserID", new { Name = newName, user.UserID })); RemoveCacheUser(oldName); } public async Task ChangeEmail(User user, string newEmail) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_PopForumsUser SET Email = @Email WHERE UserID = @UserID", new { Email = newEmail, user.UserID })); RemoveCacheUser(user.Name); } public async Task UpdateIsApproved(User user, bool isApproved) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_PopForumsUser SET IsApproved = @IsApproved WHERE UserID = @UserID", new { IsApproved = isApproved, user.UserID })); RemoveCacheUser(user.Name); } public async Task UpdateAuthorizationKey(User user, Guid key) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_PopForumsUser SET AuthorizationKey = @AuthorizationKey WHERE UserID = @UserID", new { AuthorizationKey = key, user.UserID })); RemoveCacheUser(user.Name); } public async Task UpdateTokenExpiration(User user, DateTime? tokenExpiration) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("UPDATE pf_PopForumsUser SET TokenExpiration = @tokenExpiration WHERE UserID = @UserID", new { tokenExpiration, user.UserID })); RemoveCacheUser(user.Name); } public async Task> SearchByEmail(string email) { var list = await GetList("SELECT " + PopForumsUserColumns + " FROM pf_PopForumsUser WHERE Email LIKE '%' + @Email + '%'", new { Email = email }); return list; } public async Task> SearchByName(string name) { var list = await GetList("SELECT " + PopForumsUserColumns + " FROM pf_PopForumsUser WHERE Name LIKE '%' + @Name + '%'", new { Name = name }); return list; } public async Task> SearchByRole(string role) { var list = await GetList("SELECT " + PopForumsUserColumns + " FROM pf_PopForumsUser JOIN pf_PopForumsUserRole R ON pf_PopForumsUser.UserID = R.UserID WHERE Role = @Role", new { Role = role }); return list; } public async Task> GetUsersOnline() { var cacheObject = _cacheHelper.GetCacheObject>(CacheKeys.UsersOnline); if (cacheObject != null) return cacheObject; Task> list = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => list = connection.QueryAsync("SELECT " + PopForumsUserColumns + " FROM pf_PopForumsUser JOIN pf_UserSession ON pf_PopForumsUser.UserID = pf_UserSession.UserID ORDER BY Name")); var userList = list.Result.ToList(); _cacheHelper.SetCacheObject(CacheKeys.UsersOnline, userList, 60); return userList; } public async Task> GetSubscribedUsers() { Task> list = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => list = connection.QueryAsync("SELECT " + PopForumsUserColumns + " FROM pf_PopForumsUser JOIN pf_Profile ON pf_PopForumsUser.UserID = pf_Profile.UserID WHERE pf_Profile.IsSubscribed = 1")); return list.Result.ToList(); } public Dictionary GetUsersByPointTotals(int top) { var key = CacheKeys.PointTotals + top; var cacheObject = _cacheHelper.GetCacheObject>(key); if (cacheObject != null) return cacheObject; var list = new Dictionary(); _sqlObjectFactory.GetConnection().Using(connection => connection.Query( $"SELECT TOP {top} {PopForumsUserColumns}, pf_Profile.Points FROM pf_PopForumsUser JOIN pf_Profile ON pf_PopForumsUser.UserID = pf_Profile.UserID ORDER BY pf_Profile.Points DESC", (user, points) => { list.Add(user.UserID, (user, points)); return user; }, splitOn: "Points")); _cacheHelper.SetCacheObject(key, list, 60); return list; } public async Task DeleteUser(User user) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_PopForumsUser WHERE UserID = @UserID", new { user.UserID })); RemoveCacheUser(user.Name); } private async Task> GetList(string sql, object parameters) { if (parameters == null) return new List(); Task> list = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => list = connection.QueryAsync(sql, parameters)); return list.Result.ToList(); } public async Task> GetRecentUsers() { Task> list = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => list = connection.QueryAsync(@"SELECT TOP 100 U.UserID, [Name], Email, CreationDate, [IP] FROM pf_PopForumsUser U JOIN pf_SecurityLog S ON U.UserID = S.TargetUserID WHERE SecurityLogType = 6 ORDER BY CreationDate DESC")); return list.Result.ToList(); } public async Task> GetUserNamesThatStartWith(string startingName) { Task> list = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => list = connection.QueryAsync(@"SELECT [Name] FROM pf_PopForumsUser WHERE [Name] LIKE @startingName + '%'", new { startingName })); return list.Result.ToList(); } } ================================================ FILE: src/PopForums.Sql/Repositories/UserSessionRepository.cs ================================================ namespace PopForums.Sql.Repositories; public class UserSessionRepository : IUserSessionRepository { public UserSessionRepository(ICacheHelper cacheHelper, ISqlObjectFactory sqlObjectFactory) { _cacheHelper = cacheHelper; _sqlObjectFactory = sqlObjectFactory; } private readonly ICacheHelper _cacheHelper; private readonly ISqlObjectFactory _sqlObjectFactory; public class CacheKeys { public const string CurrentSessionCount = "PopForums.Session.CurrentCount"; } public async Task CreateSession(int sessionID, int? userID, DateTime lastTime) { await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("INSERT INTO pf_UserSession (SessionID, UserID, LastTime) VALUES (@SessionID, @UserID, @LastTime)", new { SessionID = sessionID, UserID = userID, LastTime = lastTime })); } public async Task UpdateSession(int sessionID, DateTime lastTime) { Task result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.ExecuteAsync("UPDATE pf_UserSession SET LastTime = @LastTime WHERE SessionID = @SessionID", new { SessionID = sessionID, LastTime = lastTime })); return result.Result == 1; } public async Task IsSessionAnonymous(int sessionID) { Task> result = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => result = connection.QueryAsync("SELECT UserID FROM pf_UserSession WHERE SessionID = @SessionID AND UserID IS NULL", new { SessionID = sessionID })); return result.Result.Any(); } public async Task> GetAndDeleteExpiredSessions(DateTime cutOffDate) { Task> list = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => list = connection.QueryAsync("SELECT SessionID, UserID, LastTime FROM pf_UserSession WHERE LastTime < @CutOff", new { CutOff = cutOffDate })); await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_UserSession WHERE LastTime < @CutOff", new { CutOff = cutOffDate })); return list.Result.ToList(); } public async Task GetSessionIDByUserID(int userID) { Task session = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => session = connection.QueryFirstOrDefaultAsync("SELECT SessionID, UserID, LastTime FROM pf_UserSession WHERE UserID = @UserID", new { UserID = userID })); return await session; } public async Task DeleteSessions(int? userID, int sessionID) { if (userID.HasValue) await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_UserSession WHERE SessionID = @SessionID OR UserID = @UserID", new { SessionID = sessionID, UserID = userID })); else await _sqlObjectFactory.GetConnection().UsingAsync(connection => connection.ExecuteAsync("DELETE FROM pf_UserSession WHERE SessionID = @SessionID", new { SessionID = sessionID })); } public async Task GetTotalSessionCount() { var cacheObject = _cacheHelper.GetCacheObject(CacheKeys.CurrentSessionCount); if (cacheObject != 0) return cacheObject; Task count = null; await _sqlObjectFactory.GetConnection().UsingAsync(connection => count = connection.ExecuteScalarAsync("SELECT COUNT(*) FROM pf_UserSession")); _cacheHelper.SetCacheObject(CacheKeys.CurrentSessionCount, count.Result, 60); return count.Result; } } ================================================ FILE: src/PopForums.Sql/SqlObjectFactory.cs ================================================ namespace PopForums.Sql; public class SqlObjectFactory : ISqlObjectFactory { private readonly IConfig _config; public SqlObjectFactory(IConfig config) { _config = config; } public DbConnection GetConnection() { if (string.IsNullOrWhiteSpace(_config.DatabaseConnectionString)) throw new Exception("No database connection string found for POP Forums."); var connectionString = _config.DatabaseConnectionString; return new SqlConnection(connectionString); } public DbCommand GetCommand() { return new SqlCommand(); } public DbCommand GetCommand(string sql) { return new SqlCommand(sql); } public DbCommand GetCommand(string sql, DbConnection connection) { return new SqlCommand(sql, (SqlConnection)connection); } public DbParameter GetParameter(string parameterName, object value) { return new SqlParameter(parameterName, value); } } ================================================ FILE: src/PopForums.Sql/StreamResponse.cs ================================================ namespace PopForums.Sql; public class StreamResponse(Stream stream, SqlConnection connection, SqlDataReader reader) : IStreamResponse { public Stream Stream => stream; public void Dispose() { reader.Close(); connection.Close(); stream.Close(); reader.Dispose(); connection.Dispose(); stream.Dispose(); } } ================================================ FILE: src/PopForums.Test/Composers/ForumStateComposerTests.cs ================================================ using PopForums.Composers; namespace PopForums.Test.Composers; public class ForumStateComposerTests { protected ForumStateComposer GetComposer() { return new ForumStateComposer(); } public class GetState : ForumStateComposerTests { [Fact] public void MapsCorrectly() { var composer = GetComposer(); var pagerContext = new PagerContext {PageCount = 1, PageIndex = 2, PageSize = 3}; var forum = new Forum {ForumID = 4}; var result = composer.GetState(forum, pagerContext); Assert.Equal(forum.ForumID, result.ForumID); Assert.Equal(pagerContext.PageIndex, result.PageIndex); Assert.Equal(pagerContext.PageSize, result.PageSize); } } } ================================================ FILE: src/PopForums.Test/Composers/PrivateMessageStateComposerTests.cs ================================================ using System.Text.Json; using System.Text.Json.Serialization; using PopForums.Composers; namespace PopForums.Test.Composers; public class PrivateMessageStateComposerTests { protected PrivateMessageStateComposer GetComposer() { _privateMessageService = Substitute.For(); return new PrivateMessageStateComposer(_privateMessageService); } private IPrivateMessageService _privateMessageService; public class GetState : PrivateMessageStateComposerTests { [Fact] public async Task MessagesMappedWithBuffer() { var composer = GetComposer(); var jsonUsers = JsonSerializer.SerializeToElement(new[] {new {UserID = 2, Name = "Jeff"}, new {UserID = 3, Name = "Diana"}, new {UserID = 4, Name = "Simon"}}); var pm = new PrivateMessage { LastViewDate = DateTime.UtcNow, PMID = 123, Users = jsonUsers}; var posts = new List(); var post1 = new PrivateMessagePost{ PMID = pm.PMID, UserID = 2, Name = "Jeff", PostTime = new DateTime(2020,1,1), FullText = "post1", PMPostID = 1}; var post2 = new PrivateMessagePost{ PMID = pm.PMID, UserID = 3, Name = "Diana", PostTime = new DateTime(2021,1,1), FullText = "post2", PMPostID = 2}; var post3 = new PrivateMessagePost{ PMID = pm.PMID, UserID = 4, Name = "Simon", PostTime = new DateTime(2022,1,1), FullText = "post3", PMPostID = 3}; posts.Add(post1); posts.Add(post2); _privateMessageService.GetMostRecentPosts(pm.PMID, pm.LastViewDate).Returns(posts); _privateMessageService.GetPosts(pm.PMID, pm.LastViewDate).Returns(new List { post3 }); _privateMessageService.GetUsers(pm.PMID).Returns([ new PrivateMessageUser { PMID = pm.PMID, UserID = 2 }, new PrivateMessageUser { PMID = pm.PMID, UserID = 3 }, new PrivateMessageUser { PMID = pm.PMID, UserID = 4 } ]); var state = await composer.GetState(pm); Assert.Equal(post3.UserID, state.Messages[0].UserID); Assert.Equal(post3.Name, state.Messages[0].Name); Assert.Equal(post3.PostTime.ToString("o"), state.Messages[0].PostTime); Assert.Equal(post3.FullText, state.Messages[0].FullText); Assert.Equal(post3.PMPostID, state.Messages[0].PMPostID); Assert.Equal(post1.UserID, state.Messages[1].UserID); Assert.Equal(post1.Name, state.Messages[1].Name); Assert.Equal(post1.PostTime.ToString("o"), state.Messages[1].PostTime); Assert.Equal(post1.FullText, state.Messages[1].FullText); Assert.Equal(post1.PMPostID, state.Messages[1].PMPostID); Assert.Equal(post2.UserID, state.Messages[2].UserID); Assert.Equal(post2.Name, state.Messages[2].Name); Assert.Equal(post2.PostTime.ToString("o"), state.Messages[2].PostTime); Assert.Equal(post2.FullText, state.Messages[2].FullText); Assert.Equal(post2.PMPostID, state.Messages[2].PMPostID); } [Fact] public async Task NewestPostIDSet() { var composer = GetComposer(); var jsonUsers = JsonSerializer.SerializeToElement(Array.Empty()); var pm = new PrivateMessage { LastViewDate = DateTime.UtcNow, PMID = 123, Users = jsonUsers}; var posts = new List(); var post1 = new PrivateMessagePost{ PMID = pm.PMID }; var post2 = new PrivateMessagePost{ PMID = pm.PMID }; var post3 = new PrivateMessagePost{ PMID = pm.PMID }; posts.Add(post1); posts.Add(post2); _privateMessageService.GetMostRecentPosts(pm.PMID, pm.LastViewDate).Returns(posts); _privateMessageService.GetPosts(pm.PMID, pm.LastViewDate).Returns(new List { post3 }); _privateMessageService.GetUsers(pm.PMID).Returns(new List()); var state = await composer.GetState(pm); Assert.Equal(post1.PMPostID, state.NewestPostID); } [Fact] public async Task PMIDSet() { var composer = GetComposer(); var jsonUsers = JsonSerializer.SerializeToElement(Array.Empty()); var pm = new PrivateMessage { LastViewDate = DateTime.UtcNow, PMID = 123, Users = jsonUsers}; _privateMessageService.GetMostRecentPosts(pm.PMID, pm.LastViewDate).Returns(new List()); _privateMessageService.GetPosts(pm.PMID, pm.LastViewDate).Returns(new List()); _privateMessageService.GetUsers(pm.PMID).Returns(new List()); var state = await composer.GetState(pm); Assert.Equal(pm.PMID, state.PmID); } [Fact] public async Task PMUsersJsonSet() { var composer = GetComposer(); var jsonUsers = JsonSerializer.SerializeToElement(Array.Empty()); var pm = new PrivateMessage { LastViewDate = DateTime.UtcNow, PMID = 123, Users = jsonUsers}; _privateMessageService.GetMostRecentPosts(pm.PMID, pm.LastViewDate).Returns(new List()); _privateMessageService.GetPosts(pm.PMID, pm.LastViewDate).Returns(new List()); _privateMessageService.GetUsers(pm.PMID).Returns(new List()); var state = await composer.GetState(pm); Assert.Equal(pm.Users, state.Users); } [Fact] public async Task IsUserNotFoundSetToFalse() { var composer = GetComposer(); var jsonUsers = JsonSerializer.SerializeToElement(new[] {new {UserID = 2, Name = "Jeff"}, new {UserID = 3, Name = "Diana"}, new {UserID = 4, Name = "Simon"}}); var pm = new PrivateMessage { LastViewDate = DateTime.UtcNow, PMID = 123, Users = jsonUsers}; _privateMessageService.GetMostRecentPosts(pm.PMID, pm.LastViewDate).Returns(new List()); _privateMessageService.GetPosts(pm.PMID, pm.LastViewDate).Returns(new List()); _privateMessageService.GetUsers(pm.PMID).Returns([ new PrivateMessageUser { PMID = pm.PMID, UserID = 2 }, new PrivateMessageUser { PMID = pm.PMID, UserID = 3 }, new PrivateMessageUser { PMID = pm.PMID, UserID = 4 } ]); var state = await composer.GetState(pm); Assert.False(state.IsUserNotFound); } [Fact] public async Task IsUserNotFoundSetToTrue() { var composer = GetComposer(); var jsonUsers = JsonSerializer.SerializeToElement(new[] {new {UserID = 2, Name = "Jeff"}, new {UserID = 3, Name = "Diana"}, new {UserID = 4, Name = "Simon"}}); var pm = new PrivateMessage { LastViewDate = DateTime.UtcNow, PMID = 123, Users = jsonUsers}; _privateMessageService.GetMostRecentPosts(pm.PMID, pm.LastViewDate).Returns(new List()); _privateMessageService.GetPosts(pm.PMID, pm.LastViewDate).Returns(new List()); _privateMessageService.GetUsers(pm.PMID).Returns([ new PrivateMessageUser { PMID = pm.PMID, UserID = 2 }, new PrivateMessageUser { PMID = pm.PMID, UserID = 4 } ]); var state = await composer.GetState(pm); Assert.True(state.IsUserNotFound); } } } ================================================ FILE: src/PopForums.Test/Composers/TopicStateComposerTests.cs ================================================ using PopForums.Composers; namespace PopForums.Test.Composers; public class TopicStateComposerTests { protected TopicStateComposer GetComposer() { _userRetrievalShim = Substitute.For(); _settingsManager = Substitute.For(); _subscribedTopicsService = Substitute.For(); _favoriteTopicService = Substitute.For(); return new TopicStateComposer(_userRetrievalShim, _settingsManager, _subscribedTopicsService, _favoriteTopicService); } private IUserRetrievalShim _userRetrievalShim; private ISettingsManager _settingsManager; private ISubscribedTopicsService _subscribedTopicsService; private IFavoriteTopicService _favoriteTopicService; public class GetState : TopicStateComposerTests { [Fact] public async Task MapsCorrectlyWithoutUser() { var composer = GetComposer(); _userRetrievalShim.GetUser().Returns((User) null); var topic = new Topic {TopicID = 123, AnswerPostID = 789}; var result = await composer.GetState(topic, 4, 5, 6); Assert.Equal(topic.TopicID, result.TopicID); Assert.Equal(topic.AnswerPostID, result.AnswerPostID); Assert.Equal(4, result.PageIndex); Assert.Equal(5, result.PageCount); Assert.Equal(6, result.LastVisiblePostID); Assert.False(result.IsFavorite); Assert.False(result.IsSubscribed); Assert.False(result.IsImageEnabled); } [Fact] public async Task MapsCorrectlyWithUser() { var composer = GetComposer(); var user = new User {UserID = 111}; var topic = new Topic { TopicID = 123, AnswerPostID = 789 }; _userRetrievalShim.GetUser().Returns(user); _favoriteTopicService.IsTopicFavorite(user.UserID, topic.TopicID).Returns(Task.FromResult(true)); _subscribedTopicsService.IsTopicSubscribed(user.UserID, topic.TopicID).Returns(Task.FromResult(true)); _settingsManager.Current.AllowImages.Returns(true); var result = await composer.GetState(topic, 4, 5, 6); Assert.Equal(topic.TopicID, result.TopicID); Assert.Equal(topic.AnswerPostID, result.AnswerPostID); Assert.Equal(4, result.PageIndex); Assert.Equal(5, result.PageCount); Assert.Equal(6, result.LastVisiblePostID); Assert.True(result.IsFavorite); Assert.True(result.IsSubscribed); Assert.True(result.IsImageEnabled); await _favoriteTopicService.Received().IsTopicFavorite(user.UserID, topic.TopicID); await _subscribedTopicsService.Received().IsTopicSubscribed(user.UserID, topic.TopicID); } } } ================================================ FILE: src/PopForums.Test/Configuration/SettingsTests.cs ================================================ namespace PopForums.Test.Configuration; public class SettingsTests { [Fact] public void LoadDefaults() { var settingsRepo = Substitute.For(); settingsRepo.Get().Returns(new Dictionary()); var errorLog = Substitute.For(); var settingsManager = new SettingsManager(settingsRepo, errorLog); var settings = settingsManager.Current; Assert.Equal(settings.TermsOfService, String.Empty); Assert.True(settings.IsNewUserApproved); Assert.Equal(20, settings.TopicsPerPage); Assert.Equal(20, settings.PostsPerPage); Assert.Equal(settings.ForumTitle, String.Empty); } [Fact] public void LoadFromRepo() { const string tos = "blah blah blah"; const bool isNewUserApproved = false; const int topicsPerPage = 72; const int postsPerPage = 42; const string title = "superawesome forum"; var dictionary = new Dictionary { {"TermsOfService", tos}, {"IsNewUserApproved", isNewUserApproved.ToString()}, {"TopicsPerPage", topicsPerPage.ToString()}, {"PostsPerPage", postsPerPage.ToString()}, {"ForumTitle", title} }; var settingsRepo = Substitute.For(); settingsRepo.Get().Returns(dictionary); var errorLog = Substitute.For(); var settingsManager = new SettingsManager(settingsRepo, errorLog); var settings = settingsManager.Current; Assert.False(settings.IsNewUserApproved); Assert.Equal(topicsPerPage, settings.TopicsPerPage); Assert.Equal(postsPerPage, settings.PostsPerPage); Assert.Equal(title, settings.ForumTitle); settingsRepo.Received().Get(); } [Fact] public void LoadFromRepoThenCache() { var settingsRepo = Substitute.For(); settingsRepo.Get().Returns(new Dictionary()); var errorLog = Substitute.For(); var settingsManager = new SettingsManager(settingsRepo, errorLog); var settings = settingsManager.Current; var settings2 = settingsManager.Current; var settings3 = settingsManager.Current; Assert.Equal(settings, settings2); Assert.Equal(settings, settings3); settingsRepo.Received().Get(); } [Fact] public void LoadFromRepoWhenStale() { var settingsRepo = Substitute.For(); settingsRepo.Get().Returns(new Dictionary()); var errorLog = Substitute.For(); var settingsManager = new SettingsManager(settingsRepo, errorLog); _ = settingsManager.Current; settingsRepo.OnSettingsInvalidated += Raise.Event(); _ = settingsManager.Current; settingsRepo.Received(2).Get(); } [Fact] public void DoNotLoadFromRepoWhenNotStale() { var settingsRepo = Substitute.For(); settingsRepo.Get().Returns(new Dictionary()); var errorLog = Substitute.For(); var settingsManager = new SettingsManager(settingsRepo, errorLog); _ = settingsManager.Current; _ = settingsManager.Current; settingsRepo.Received(1).Get(); } [Fact] public void SaveCurrent() { const string tos = "blah blah blah"; const bool isNewUserApproved = false; const int topicsPerPage = 72; const int postsPerPage = 42; const string title = "superawesome forum"; const int minimumSecondsBetweenPosts = 33; const string smtpServer = "mail.?.com"; const int smtpPort = 69; const string mailerAddress = "a@b.com"; const bool useEsmtp = true; const string smtpUser = "b@c.com"; const string smtpPassword = "jkl"; const int mailSendingInverval = 500; const bool useSslSmtp = true; const int sessionLength = 20; const string censorWords = "shit"; const string censorCharacter = "x"; const bool allowImages = true; const bool logSecurity = false; const bool logModeration = false; const bool logErrors = false; const bool isNewUserImageApproved = true; const int searchIndexingInterval = 768; const bool isSearchIndexingEnabled = false; const bool isMailerEnabled = false; const int userImageMaxHeight = 999; const int userImageMaxWidth = 888; const int userImageMaxkBytes = 77; const int userAvatarMaxHeight = 665; const int userAvatarMaxWidth = 554; const int userAvatarMaxkBytes = 33; const string mailSignature = "this is the sig"; const int awardCalcInterval = 5230; const int mailerQuantity = 914; const bool useGoogleLogin = true; const bool useFacebookLogin = true; const string facebookAppID = "oiwoeighw"; const string facebookAppSecret = "oiwhwohgcgr"; const bool useMicrosoftLogin = true; const string microsoftClientID = "hhvcwefwege"; const string microsoftClientSecret = "oiwhgoigrccaa"; const int youTubeHeight = 360; const int youTubeWidth = 640; const string googleClientId = "ohigfewgf"; const string googleClientSecret = "y0yt0w4gweg"; const bool useOAuth2Login = true; const string oAuth2ClientID = "efew"; const string oAuth2ClientSecret = "cons"; const string oAuth2DisplayName = "we3t"; const string oAuth2LoginUrl = "ef"; const string oAuth2TokenUrl = "w"; const string oAuth2Scope = "email"; const bool isClosingAgedTopics = true; const int closeAgedTopicsDays = 757; const bool isPrivateForumInstance = true; const string replyToAddress = "D@e.com"; const int postImageMaxHeight = 654; const int postImageMaxWidth = 980; const int postImageMaxkBytes = 631; var dictionary = new Dictionary { {"TermsOfService", tos}, {"IsNewUserApproved", isNewUserApproved}, {"TopicsPerPage", topicsPerPage}, {"PostsPerPage", postsPerPage}, {"ForumTitle", title}, {"MinimumSecondsBetweenPosts", minimumSecondsBetweenPosts}, {"SmtpServer", smtpServer}, {"SmtpPort", smtpPort}, {"MailerAddress", mailerAddress}, {"ReplyToAddress", replyToAddress}, {"UseEsmtp", useEsmtp}, {"SmtpUser", smtpUser}, {"SmtpPassword", smtpPassword}, {"MailSendingInverval", mailSendingInverval}, {"UseSslSmtp", useSslSmtp}, {"SessionLength", sessionLength}, {"CensorWords", censorWords}, {"CensorCharacter", censorCharacter}, {"AllowImages", allowImages}, {"LogSecurity", logSecurity}, {"LogModeration", logModeration}, {"LogErrors", logErrors}, {"IsNewUserImageApproved", isNewUserImageApproved}, {"SearchIndexingInterval", searchIndexingInterval}, {"IsSearchIndexingEnabled", isSearchIndexingEnabled}, {"IsMailerEnabled", isMailerEnabled}, {"UserImageMaxHeight", userImageMaxHeight}, {"UserImageMaxWidth", userImageMaxWidth}, {"UserImageMaxkBytes", userImageMaxkBytes}, {"UserAvatarMaxHeight", userAvatarMaxHeight}, {"UserAvatarMaxWidth", userAvatarMaxWidth}, {"UserAvatarMaxkBytes", userAvatarMaxkBytes}, {"MailSignature", mailSignature}, {"ScoringGameCalculatorInterval", awardCalcInterval}, {"MailerQuantity", mailerQuantity}, {"UseGoogleLogin", useGoogleLogin}, {"UseFacebookLogin", useFacebookLogin}, {"FacebookAppID", facebookAppID}, {"FacebookAppSecret", facebookAppSecret}, {"UseMicrosoftLogin", useMicrosoftLogin}, {"MicrosoftClientID", microsoftClientID}, {"MicrosoftClientSecret", microsoftClientSecret}, {"YouTubeHeight", youTubeHeight}, {"YouTubeWidth", youTubeWidth}, {"GoogleClientId", googleClientId}, {"GoogleClientSecret", googleClientSecret}, {"UseOAuth2Login", useOAuth2Login}, {"OAuth2ClientID", oAuth2ClientID }, {"OAuth2ClientSecret", oAuth2ClientSecret }, {"OAuth2LoginUrl", oAuth2LoginUrl }, {"OAuth2TokenUrl", oAuth2TokenUrl }, {"OAuth2DisplayName", oAuth2DisplayName }, {"OAuth2Scope", oAuth2Scope }, {"IsClosingAgedTopics", isClosingAgedTopics}, {"CloseAgedTopicsDays", closeAgedTopicsDays}, {"IsPrivateForumInstance", isPrivateForumInstance}, {"PostImageMaxHeight", postImageMaxHeight}, {"PostImageMaxWidth", postImageMaxWidth}, {"PostImageMaxkBytes", postImageMaxkBytes} }; var settingsRepo = Substitute.For(); settingsRepo.Get().Returns(new Dictionary()); var errorLog = Substitute.For(); var settingsManager = new SettingsManager(settingsRepo, errorLog); var settings = settingsManager.Current; settings.TermsOfService = tos; settings.IsNewUserApproved = isNewUserApproved; settings.TopicsPerPage = topicsPerPage; settings.PostsPerPage = postsPerPage; settings.ForumTitle = title; settings.MinimumSecondsBetweenPosts = minimumSecondsBetweenPosts; settings.SmtpServer = smtpServer; settings.SmtpPort = smtpPort; settings.MailerAddress = mailerAddress; settings.ReplyToAddress = replyToAddress; settings.UseEsmtp = useEsmtp; settings.SmtpUser = smtpUser; settings.SmtpPassword = smtpPassword; settings.MailSendingInverval = mailSendingInverval; settings.UseSslSmtp = useSslSmtp; settings.SessionLength = sessionLength; settings.CensorWords = censorWords; settings.CensorCharacter = censorCharacter; settings.AllowImages = allowImages; settings.LogSecurity = logSecurity; settings.LogModeration = logModeration; settings.LogErrors = logErrors; settings.IsNewUserImageApproved = isNewUserImageApproved; settings.SearchIndexingInterval = searchIndexingInterval; settings.IsSearchIndexingEnabled = isSearchIndexingEnabled; settings.IsMailerEnabled = isMailerEnabled; settings.UserImageMaxHeight = userImageMaxHeight; settings.UserImageMaxWidth = userImageMaxWidth; settings.UserImageMaxkBytes = userImageMaxkBytes; settings.UserAvatarMaxHeight = userAvatarMaxHeight; settings.UserAvatarMaxWidth = userAvatarMaxWidth; settings.UserAvatarMaxkBytes = userAvatarMaxkBytes; settings.MailSignature = mailSignature; settings.ScoringGameCalculatorInterval = awardCalcInterval; settings.MailerQuantity = mailerQuantity; settings.UseGoogleLogin = useGoogleLogin; settings.UseFacebookLogin = useFacebookLogin; settings.FacebookAppID = facebookAppID; settings.FacebookAppSecret = facebookAppSecret; settings.UseMicrosoftLogin = useMicrosoftLogin; settings.MicrosoftClientID = microsoftClientID; settings.MicrosoftClientSecret = microsoftClientSecret; settings.YouTubeHeight = youTubeHeight; settings.YouTubeWidth = youTubeWidth; settings.GoogleClientId = googleClientId; settings.GoogleClientSecret = googleClientSecret; settings.UseOAuth2Login = useOAuth2Login; settings.OAuth2ClientID = oAuth2ClientID; settings.OAuth2ClientSecret = oAuth2ClientSecret; settings.OAuth2LoginUrl = oAuth2LoginUrl; settings.OAuth2TokenUrl = oAuth2TokenUrl; settings.OAuth2DisplayName = oAuth2DisplayName; settings.OAuth2Scope = oAuth2Scope; settings.IsClosingAgedTopics = isClosingAgedTopics; settings.CloseAgedTopicsDays = closeAgedTopicsDays; settings.IsPrivateForumInstance = isPrivateForumInstance; settings.PostImageMaxHeight = postImageMaxHeight; settings.PostImageMaxWidth = postImageMaxWidth; settings.PostImageMaxkBytes = postImageMaxkBytes; settingsManager.SaveCurrent(); settingsRepo.Received().Save(Arg.Is>(x => x.SequenceEqual(dictionary))); } } ================================================ FILE: src/PopForums.Test/Email/EmailWorkerTests.cs ================================================ using NSubstitute.Extensions; using NSubstitute.ReturnsExtensions; namespace PopForums.Test.Email; public class EmailWorkerTests { private ISettingsManager _settingsManager; private ISmtpWrapper _smtpWrapper; private IQueuedEmailMessageRepository _queuedEmailMessageRepository; private IEmailQueueRepository _emailQueueRepository; private IErrorLog _errorLog; private IEmailWorker GetWorker() { _settingsManager = Substitute.For(); _smtpWrapper = Substitute.For(); _queuedEmailMessageRepository = Substitute.For(); _emailQueueRepository = Substitute.For(); _errorLog = Substitute.For(); return new EmailWorker(_settingsManager, _smtpWrapper, _queuedEmailMessageRepository, _emailQueueRepository, _errorLog); } [Fact] public void DoNothingWhenNoPayload() { var worker = GetWorker(); _emailQueueRepository.Dequeue().ReturnsNull(); worker.Execute(); _queuedEmailMessageRepository.DidNotReceiveWithAnyArgs().GetMessage(Arg.Any()); _queuedEmailMessageRepository.DidNotReceiveWithAnyArgs().DeleteMessage(Arg.Any()); _smtpWrapper.DidNotReceiveWithAnyArgs().Send(Arg.Any()); _errorLog.DidNotReceiveWithAnyArgs().Log(Arg.Any(), Arg.Any()); } [Fact] public async Task LogExceptionWhenEmailQueuePayloadTypeIsDeleteMassMessage() { var worker = GetWorker(); _settingsManager.Current.MailerQuantity.Returns(4); _emailQueueRepository.Dequeue().Returns(new EmailQueuePayload { EmailQueuePayloadType = EmailQueuePayloadType.DeleteMassMessage }); await worker.Execute(); _errorLog.Received(1).Log(Arg.Any(), ErrorSeverity.Error); } [Fact] public async Task ProcessAMessage() { var worker = GetWorker(); _settingsManager.Current.MailerQuantity.Returns(4); var payload = new EmailQueuePayload { EmailQueuePayloadType = EmailQueuePayloadType.MassMessage, MessageID = 1, ToEmail = "test@example.com", ToName = "Test User" }; var queuedMessage = new QueuedEmailMessage { MessageID = 1 }; _emailQueueRepository.Dequeue().Returns(payload, null, null); _queuedEmailMessageRepository.GetMessage(1).Returns(queuedMessage); await worker.Execute(); Assert.Equal("test@example.com", queuedMessage.ToEmail); Assert.Equal("Test User", queuedMessage.ToName); await _queuedEmailMessageRepository.Received(1).DeleteMessage(1); _smtpWrapper.Received(1).Send(queuedMessage); } [Fact] public async Task DoNothingWhenQueuedMessageIsNull() { var worker = GetWorker(); _settingsManager.Current.MailerQuantity.Returns(4); var payload = new EmailQueuePayload { EmailQueuePayloadType = EmailQueuePayloadType.MassMessage, MessageID = 1 }; _emailQueueRepository.Dequeue().Returns(payload); _queuedEmailMessageRepository.GetMessage(1).ReturnsNull(); await worker.Execute(); await _queuedEmailMessageRepository.DidNotReceiveWithAnyArgs().DeleteMessage(Arg.Any()); _smtpWrapper.DidNotReceiveWithAnyArgs().Send(Arg.Any()); } [Fact] public async Task LogExceptionWhenSmtpSendFails() { var worker = GetWorker(); _settingsManager.Current.MailerQuantity.Returns(4); var payload = new EmailQueuePayload { EmailQueuePayloadType = EmailQueuePayloadType.DeleteMassMessage, MessageID = 1 }; var queuedMessage = new QueuedEmailMessage { MessageID = 1 }; _emailQueueRepository.Dequeue().Returns(payload); _queuedEmailMessageRepository.GetMessage(1).Returns(queuedMessage); _smtpWrapper.When(x => x.Send(queuedMessage)).Do(_ => throw new Exception()); await worker.Execute(); _errorLog.Received(1).Log(Arg.Any(), ErrorSeverity.Error); } } ================================================ FILE: src/PopForums.Test/Email/NewAccountMailerTests.cs ================================================ namespace PopForums.Test.Email; public class NewAccountMailerTests { [Fact] public void SendCallsSmtpWrapper() { var wrapper = Substitute.For(); var resultMessage = new EmailMessage(); wrapper.Send(Arg.Do(msg => resultMessage = msg)).Returns(SmtpStatusCode.Ok); const string mailerAddress = "a@b.com"; const string forumTitle = "superawesome"; var user = UserTest.GetTestUser(); var settings = new Settings { MailerAddress = mailerAddress, ForumTitle = forumTitle}; var settingsManager = Substitute.For(); settingsManager.Current.Returns(settings); var mailer = new NewAccountMailer(settingsManager, wrapper); var result = mailer.Send(user, "http://blah/"); Assert.Equal(SmtpStatusCode.Ok, result); Assert.Equal(resultMessage.ToName, user.Name); Assert.Equal(resultMessage.ToEmail, user.Email); Assert.Equal(forumTitle, resultMessage.FromName); } } ================================================ FILE: src/PopForums.Test/Extensions/StringTests.cs ================================================ namespace PopForums.Test.Extensions; public class StringTests { [Fact] public void GetSHA256HashString() { var output = "fred".GetSHA256Hash(); Assert.Equal("0M/C5TGbgs3HGjOHPoJsk9fuETY/iskcT6Oiz80ihuU=", output); } [Fact] public void GetMD5HashString() { var output = "fred".GetMD5Hash(); Assert.Equal("VwqQv7+MfqtdxdTiaDLVsQ==", output); } [Fact] public void IsEmailTest() { Assert.True("a@b.com".IsEmailAddress()); Assert.True("scottgu@microsoft.com".IsEmailAddress()); Assert.True("obama@whitehouse.gov".IsEmailAddress()); Assert.True("a_b@c.net".IsEmailAddress()); Assert.True("a.b@site.co.uk".IsEmailAddress()); Assert.True("ora@mixedmedia.studio".IsEmailAddress()); } [Fact] public void IsNoteEmailTest() { Assert.False("a@c".IsEmailAddress()); Assert.False("abc@examplecom".IsEmailAddress()); Assert.False("ora.mixedmedia.studio".IsEmailAddress()); Assert.False("a a@c.com".IsEmailAddress()); Assert.False("aa@c a.com".IsEmailAddress()); Assert.False("aa@coishd!iwe.com".IsEmailAddress()); } [Fact] public void UrlNameTest() { Assert.Equal("abc-def-ghi", "abc def ghi".ToUrlName()); Assert.Equal("abcdef-ghi", "abcdef-ghi".ToUrlName()); Assert.Equal("abcdef-ghi", "abc.def-ghi".ToUrlName()); Assert.Equal("abcdefghi", "abc#def/ghi".ToUrlName()); Assert.Equal("abc----defghi", "abc def*ghi".ToUrlName()); } [Fact] public void UrlNameUniqueTest() { var list = new List { "forum-title", "forum-title-but-not", "forum-title-2" }; const string title = "forum-title"; var result = title.ToUniqueUrlName(list); Assert.Equal("forum-title-3", result); } [Fact] public void UrlNameUniqueTestForPlantedDupe() { var list = new List { "forum-title", "forum-title-2" }; const string title = "forum-title"; var result = title.ToUniqueUrlName(list); Assert.Equal("forum-title-3", result); } [Fact] public void UrlNameUniqueTestForDoubleDigits() { var list = new List { "forum-title", "forum-title-1", "forum-title-2", "forum-title-3", "forum-title-4", "forum-title-5", "forum-title-6", "forum-title-7", "forum-title-8", "forum-title-9", "forum-title-10", "forum-title-11" }; const string title = "forum-title"; var result = title.ToUniqueUrlName(list); Assert.Equal("forum-title-12", result); } [Fact] public void TrimmerTest() { var result = "123456789012345678901234567890".Trimmer(22); Assert.Equal("123456789...1234567890", result); } } ================================================ FILE: src/PopForums.Test/ExternalLogin/ExternalUserAssociationManagerTests.cs ================================================ namespace PopForums.Test.ExternalLogin; public class ExternalUserAssociationManagerTests { private ExternalUserAssociationManager GetManager() { _externalUserAssociationRepo = Substitute.For(); _userRepo = Substitute.For(); _securityLogService = Substitute.For(); return new ExternalUserAssociationManager(_externalUserAssociationRepo, _userRepo, _securityLogService); } private IExternalUserAssociationRepository _externalUserAssociationRepo; private IUserRepository _userRepo; private ISecurityLogService _securityLogService; [Fact] public async Task ExternalUserAssociationCheckThrowsWithNullArg() { var manager = GetManager(); await Assert.ThrowsAsync(async () => await manager.ExternalUserAssociationCheck(null, "")); } [Fact] public async Task ExternalUserAssociationCheckResultFalseWithNullsIfNoMatchingAssociation() { var manager = GetManager(); _externalUserAssociationRepo.Get(Arg.Any(), Arg.Any()).Returns((ExternalUserAssociation) null); var result = await manager.ExternalUserAssociationCheck(new ExternalLoginInfo("", "", ""), ""); Assert.False(result.Successful); Assert.Null(result.ExternalUserAssociation); Assert.Null(result.User); } [Fact] public async Task ExternalUserAssociationCheckResultFalseWithNullsIfNoMatchingUser() { var manager = GetManager(); _externalUserAssociationRepo.Get(Arg.Any(), Arg.Any()).Returns(Task.FromResult(new ExternalUserAssociation())); _userRepo.GetUser(Arg.Any()).Returns((User) null); var result = await manager.ExternalUserAssociationCheck(new ExternalLoginInfo("", "", ""), ""); Assert.False(result.Successful); Assert.Null(result.ExternalUserAssociation); Assert.Null(result.User); } [Fact] public async Task ExternalUserAssociationCheckResultTrueWithHydratedResultIfMatchingAssociationAndUser() { var manager = GetManager(); var association = new ExternalUserAssociation { Issuer = "Google", UserID = 123, ProviderKey = "abc"}; var user = new User {UserID = association.UserID}; _externalUserAssociationRepo.Get(association.Issuer, association.ProviderKey).Returns(Task.FromResult(association)); _userRepo.GetUser(association.UserID).Returns(Task.FromResult(user)); var authResult = new ExternalLoginInfo("Google", "abc", ""); var result = await manager.ExternalUserAssociationCheck(authResult, ""); Assert.True(result.Successful); Assert.Same(user, result.User); Assert.Same(association, result.ExternalUserAssociation); } [Fact] public async Task ExternalUserAssociationCheckResultTrueCallsSecurityLog() { var manager = GetManager(); var association = new ExternalUserAssociation { Issuer = "Google", UserID = 123, ProviderKey = "abc" }; var user = new User {UserID = association.UserID}; _externalUserAssociationRepo.Get(association.Issuer, association.ProviderKey).Returns(Task.FromResult(association)); _userRepo.GetUser(association.UserID).Returns(Task.FromResult(user)); const string ip = "1.1.1.1"; var authResult = new ExternalLoginInfo("Google", "abc", ""); await manager.ExternalUserAssociationCheck(authResult, ip); await _securityLogService.Received().CreateLogEntry(user, user, ip, Arg.Any(), SecurityLogType.ExternalAssociationCheckSuccessful); } [Fact] public async Task ExternalUserAssociationCheckResultFalseNoMatchCallsSecurityLog() { var manager = GetManager(); _externalUserAssociationRepo.Get(Arg.Any(), Arg.Any()).Returns((ExternalUserAssociation)null); const string ip = "1.1.1.1"; var authResult = new ExternalLoginInfo("Google", "abc", ""); await manager.ExternalUserAssociationCheck(authResult, ip); await _securityLogService.Received().CreateLogEntry((int?)null, null, ip, Arg.Any(), SecurityLogType.ExternalAssociationCheckFailed); } [Fact] public async Task ExternalUserAssociationCheckResultFalseNoUserCallsSecurityLog() { var manager = GetManager(); var association = new ExternalUserAssociation { Issuer = "Google", UserID = 123, ProviderKey = "abc" }; _externalUserAssociationRepo.Get(association.Issuer, association.ProviderKey).Returns(Task.FromResult(association)); _userRepo.GetUser(association.UserID).Returns((User)null); const string ip = "1.1.1.1"; var authResult = new ExternalLoginInfo("Google", "abc", ""); await manager.ExternalUserAssociationCheck(authResult, ip); await _securityLogService.Received().CreateLogEntry((int?)null, null, ip, Arg.Any(), SecurityLogType.ExternalAssociationCheckFailed); } [Fact] public async Task AssociateThrowsWithNullUser() { var manager = GetManager(); await Assert.ThrowsAsync(async () => await manager.Associate(null, default, String.Empty)); } [Fact] public async Task AssociateNeverCallsRepoWithNullExternalAuthResult() { var manager = GetManager(); await manager.Associate(new User(), null, String.Empty); await _externalUserAssociationRepo.DidNotReceive().Save(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task AssociateThrowsWithNoProviderKey() { var manager = GetManager(); await Assert.ThrowsAsync(async () => await manager.Associate(new User(), new ExternalLoginInfo("efwef", "", ""), string.Empty)); } [Fact] public async Task AssociateMapsObjectsToRepoCall() { var manager = GetManager(); var user = new User {UserID = 123}; var externalAuthResult = new ExternalLoginInfo("wegggw", "wfweg", "wewg"); await manager.Associate(user, externalAuthResult, String.Empty); await _externalUserAssociationRepo.Received().Save(user.UserID, externalAuthResult.LoginProvider, externalAuthResult.ProviderKey, externalAuthResult.ProviderDisplayName); } [Fact] public async Task AssociateSuccessCallsSecurityLog() { var manager = GetManager(); var user = new User { UserID = 123 }; var externalAuthResult = new ExternalLoginInfo("wegggw", "wfweg", "wewg"); const string ip = "1.1.1.1"; await manager.Associate(user, externalAuthResult, ip); await _securityLogService.Received().CreateLogEntry(user, user, ip, Arg.Any(), SecurityLogType.ExternalAssociationSet); } [Fact] public async Task GetExternalUserAssociationsCallsRepoByUserID() { var manager = GetManager(); var user = new User { UserID = 123 }; await _externalUserAssociationRepo.GetByUser(user.UserID); await manager.GetExternalUserAssociations(user); await _externalUserAssociationRepo.Received().GetByUser(user.UserID); } [Fact] public async Task GetExternalUserAssociationsReturnsCollectionFromRepo() { var manager = GetManager(); var user = new User { UserID = 123 }; var collection = new List(); _externalUserAssociationRepo.GetByUser(user.UserID).Returns(Task.FromResult(collection)); var result = await manager.GetExternalUserAssociations(user); Assert.Same(collection, result); } [Fact] public async Task RemoveAssociationNeverCallsRepoIfNoAssociationIsFound() { var manager = GetManager(); _externalUserAssociationRepo.Get(Arg.Any()).Returns((ExternalUserAssociation) null); await manager.RemoveAssociation(new User(), 4556, String.Empty); await _externalUserAssociationRepo.DidNotReceive().Delete(Arg.Any()); } [Fact] public async Task RemoveAssociationLogsTheRemoval() { var manager = GetManager(); var association = new ExternalUserAssociation {ExternalUserAssociationID = 123, Issuer = "Google", Name = "Jeffy", ProviderKey = "oihfoihfef", UserID = 456}; var user = new User {UserID = association.UserID}; const string ip = "1.1.1.1"; _externalUserAssociationRepo.Get(association.ExternalUserAssociationID).Returns(Task.FromResult(association)); await manager.RemoveAssociation(user, association.ExternalUserAssociationID, ip); await _securityLogService.Received().CreateLogEntry(user, user, ip, Arg.Any(), SecurityLogType.ExternalAssociationRemoved); } [Fact] public async Task RemoveAssociationThrowsIfUserIDsDontMatch() { var manager = GetManager(); var association = new ExternalUserAssociation { ExternalUserAssociationID = 123, UserID = 456 }; var user = new User { UserID = 789 }; _externalUserAssociationRepo.Get(association.ExternalUserAssociationID).Returns(Task.FromResult(association)); await Assert.ThrowsAsync(async () => await manager.RemoveAssociation(user, association.ExternalUserAssociationID, string.Empty)); } [Fact] public async Task RemoveAssociationCallsRepoOnSuccessfulMatch() { var manager = GetManager(); var association = new ExternalUserAssociation { ExternalUserAssociationID = 123, UserID = 456 }; var user = new User { UserID = association.UserID }; _externalUserAssociationRepo.Get(association.ExternalUserAssociationID).Returns(Task.FromResult(association)); await manager.RemoveAssociation(user, association.ExternalUserAssociationID, String.Empty); await _externalUserAssociationRepo.Received().Delete(association.ExternalUserAssociationID); } [Fact] public async Task GetExternalUserAssociationsThrowsIfAssociationDoesntMatchUser() { var manager = GetManager(); var association = new ExternalUserAssociation { ExternalUserAssociationID = 456, UserID = 789}; var user = new User(); _externalUserAssociationRepo.Get(association.ExternalUserAssociationID).Returns(Task.FromResult(association)); await Assert.ThrowsAsync(async () => await manager.RemoveAssociation(user, association.ExternalUserAssociationID, string.Empty)); } [Fact] public async Task GetExternalUserAssociationsCallsRepoWithMatchingUserIDs() { var manager = GetManager(); var user = new User { UserID = 123 }; var association = new ExternalUserAssociation { ExternalUserAssociationID = 456, UserID = user.UserID }; _externalUserAssociationRepo.Get(association.ExternalUserAssociationID).Returns(Task.FromResult(association)); await manager.RemoveAssociation(user, association.ExternalUserAssociationID, String.Empty); await _externalUserAssociationRepo.Received().Delete(association.ExternalUserAssociationID); } } ================================================ FILE: src/PopForums.Test/Global.cs ================================================ global using System; global using System.Collections.Generic; global using System.Linq; global using System.Reflection; global using System.Security; global using System.Text.RegularExpressions; global using System.Threading.Tasks; global using Microsoft.AspNetCore.Mvc; global using PopForums.Configuration; global using PopForums.Email; global using PopForums.Extensions; global using PopForums.ExternalLogin; global using PopForums.Feeds; global using PopForums.Messaging; global using PopForums.Models; global using PopForums.Repositories; global using PopForums.ScoringGame; global using PopForums.Services; global using PopForums.Mvc.Areas.Forums.Controllers; global using PopForums.Mvc.Areas.Forums.Services; global using PopForums.Test.Models; global using NSubstitute; global using Xunit; ================================================ FILE: src/PopForums.Test/Messaging/NotificationAdapterTests.cs ================================================ using PopForums.Messaging.Models; namespace PopForums.Test.Messaging; public class NotificationAdapterTests { protected NotificationAdapter GetAdapter() { _notificationManager = Substitute.For(); return new NotificationAdapter(_notificationManager); } private INotificationManager _notificationManager; public class Reply : NotificationAdapterTests { [Fact] public async Task ManagerCalledWithCorrectValues() { var adapter = GetAdapter(); var name = "Jeff"; var title = "The Topic"; var topicID = 123; var userID = 456; var tenantID = "cb"; ReplyData replyData = null; await _notificationManager.ProcessNotification(userID, NotificationType.NewReply, topicID, Arg.Do(x => replyData = x), tenantID); await adapter.Reply(name, title, topicID, userID, tenantID); Assert.Equal(name, replyData.PostName); Assert.Equal(topicID, replyData.TopicID); Assert.Equal(title, replyData.Title); await _notificationManager.Received().ProcessNotification(userID, NotificationType.NewReply, topicID, Arg.Any(), tenantID); } } public class Vote : NotificationAdapterTests { [Fact] public async Task ManagerCalledWithCorrectValues() { var adapter = GetAdapter(); var name = "Jeff"; var title = "The Topic"; var postID = 123; var userID = 456; VoteData voteData = null; await _notificationManager.ProcessNotification(userID, NotificationType.VoteUp, postID, Arg.Do(x => voteData = x)); await adapter.Vote(name, title, postID, userID); Assert.Equal(name, voteData.VoterName); Assert.Equal(title, voteData.Title); Assert.Equal(postID, voteData.PostID); await _notificationManager.Received().ProcessNotification(userID, NotificationType.VoteUp, postID, Arg.Any()); } } public class QuestionAnswer : NotificationAdapterTests { [Fact] public async Task ManagerCalledWithCorrectValues() { var adapter = GetAdapter(); var askerName = "Jeff"; var title = "The Topic"; var postID = 123; var userID = 456; QuestionData questionData = null; await _notificationManager.ProcessNotification(userID, NotificationType.QuestionAnswered, postID, Arg.Do(x => questionData = x)); await adapter.QuestionAnswer(askerName, title, postID, userID); Assert.Equal(askerName, questionData.AskerName); Assert.Equal(title, questionData.Title); Assert.Equal(postID, questionData.PostID); await _notificationManager.Received().ProcessNotification(userID, NotificationType.QuestionAnswered, postID, Arg.Any()); } } public class Award : NotificationAdapterTests { [Fact] public async Task ManagerCalledWithCorrectValues() { var adapter = GetAdapter(); var title = "The Award"; var userID = 456; AwardData awardData = null; await _notificationManager.ProcessNotification(userID, NotificationType.Award, Arg.Any(), Arg.Do(x => awardData = x), null); await adapter.Award(title, userID); await _notificationManager.Received().ProcessNotification(userID, NotificationType.Award, Arg.Any(), Arg.Any(), null); Assert.Equal(title, awardData.Title); } } } ================================================ FILE: src/PopForums.Test/Messaging/NotificationManagerTests.cs ================================================ using System.Text.Json; namespace PopForums.Test.Messaging; public class NotificationManagerTests { protected NotificationManager GetManager() { _notificationRepository = Substitute.For(); _broker = Substitute.For(); return new NotificationManager(_notificationRepository, _broker); } private INotificationRepository _notificationRepository; private IBroker _broker; public class ProcessNotification : NotificationManagerTests { [Fact] public async Task FieldsMapToUpdate() { var manager = GetManager(); var userID = 1; var contextID = 2; var notificationType = NotificationType.NewReply; var data = new {a = 123, b = "xyz"}; var unreadCount = 42; Notification result = null; _notificationRepository.UpdateNotification(Arg.Do(x => result = x)).Returns(Task.FromResult(1)); _notificationRepository.GetUnreadNotificationCount(userID).Returns(Task.FromResult(unreadCount)); await manager.ProcessNotification(userID, notificationType, contextID, data); Assert.Equal(userID, result.UserID); Assert.False(result.IsRead); Assert.Equal(notificationType, result.NotificationType); Assert.Equal(contextID, result.ContextID); var serializedData = JsonSerializer.SerializeToElement(data); Assert.Equal(serializedData.ToString(), result.Data.ToString()); Assert.Equal(unreadCount, result.UnreadCount); } [Fact] public async Task CreateNotCalledOnSuccessfulUpdate() { var manager = GetManager(); _notificationRepository.UpdateNotification(Arg.Any()).Returns(Task.FromResult(1)); await manager.ProcessNotification(1, NotificationType.NewReply, 2, new {}); await _notificationRepository.DidNotReceive().CreateNotification(Arg.Any()); } [Fact] public async Task FieldsMapToCreate() { var manager = GetManager(); var userID = 1; var contextID = 2; var notificationType = NotificationType.NewReply; var data = new { a = 123, b = "xyz" }; Notification result = null; _notificationRepository.UpdateNotification(Arg.Any()).Returns(Task.FromResult(0)); await _notificationRepository.CreateNotification(Arg.Do(x => result = x)); await manager.ProcessNotification(userID, notificationType, contextID, data); Assert.Equal(userID, result.UserID); Assert.False(result.IsRead); Assert.Equal(notificationType, result.NotificationType); Assert.Equal(contextID, result.ContextID); var serializedData = JsonSerializer.SerializeToElement(data); Assert.Equal(serializedData.ToString(), result.Data.ToString()); } [Fact] public async Task FieldsMapToNotifyUser() { var manager = GetManager(); var userID = 1; var contextID = 2; var notificationType = NotificationType.NewReply; var data = new { a = 123, b = "xyz" }; Notification result = null; _notificationRepository.UpdateNotification(Arg.Any()).Returns(Task.FromResult(1)); _broker.NotifyUser(Arg.Do(x => result = x)); await manager.ProcessNotification(userID, notificationType, contextID, data); Assert.Equal(userID, result.UserID); Assert.False(result.IsRead); Assert.Equal(notificationType, result.NotificationType); Assert.Equal(contextID, result.ContextID); var serializedData = JsonSerializer.SerializeToElement(data); Assert.Equal(serializedData.ToString(), result.Data.ToString()); } } public class GetUnreadNotificationCount : NotificationManagerTests { [Fact] public async Task Over100Returns100() { var manager = GetManager(); const int userID = 123; _notificationRepository.GetUnreadNotificationCount(userID).Returns(Task.FromResult(101)); var result = await manager.GetUnreadNotificationCount(userID); Assert.Equal(100, result); } [Fact] public async Task Under100ReturnsRepoValue() { var manager = GetManager(); const int userID = 123; _notificationRepository.GetUnreadNotificationCount(userID).Returns(Task.FromResult(99)); var result = await manager.GetUnreadNotificationCount(userID); Assert.Equal(99, result); } [Fact] public async Task The100Returns100() { var manager = GetManager(); const int userID = 123; _notificationRepository.GetUnreadNotificationCount(userID).Returns(Task.FromResult(100)); var result = await manager.GetUnreadNotificationCount(userID); Assert.Equal(100, result); } } } ================================================ FILE: src/PopForums.Test/Models/ForumHomeContainerTests.cs ================================================ namespace PopForums.Test.Models; public class ForumHomeContainerTests { [Fact] public void UncategorizedForumsShowUpOnProperty() { var c1 = new Category { CategoryID = 1 }; var c2 = new Category { CategoryID = 2 }; var f1 = new Forum {ForumID = 1, CategoryID = null}; var f2 = new Forum { ForumID = 2, CategoryID = 1}; var f3 = new Forum { ForumID = 3, CategoryID = 2}; var f4 = new Forum { ForumID = 4, CategoryID = 0}; var cats = new List {c1, c2}; var forums = new List {f1, f2, f3, f4}; var container = new CategorizedForumContainer(cats, forums); Assert.Contains(f1, container.UncategorizedForums); Assert.DoesNotContain(f2, container.UncategorizedForums); Assert.DoesNotContain(f3, container.UncategorizedForums); Assert.Contains(f4, container.UncategorizedForums); } [Fact] public void UncategorizedInCorrectOrder() { var f1 = new Forum { ForumID = 1, SortOrder = 5 }; var f2 = new Forum { ForumID = 2, SortOrder = 1 }; var f3 = new Forum { ForumID = 3, SortOrder = 3 }; var forums = new List { f1, f2, f3 }; var container = new CategorizedForumContainer(new List(), forums); Assert.True(container.UncategorizedForums[0] == f2); Assert.True(container.UncategorizedForums[1] == f3); Assert.True(container.UncategorizedForums[2] == f1); } [Fact] public void CategoriesInCorrectOrder() { var c1 = new Category { CategoryID = 1, SortOrder = 5 }; var c2 = new Category { CategoryID = 2, SortOrder = 1 }; var c3 = new Category { CategoryID = 3, SortOrder = 3 }; var f1 = new Forum { ForumID = 1, CategoryID = 1 }; var f2 = new Forum { ForumID = 2, CategoryID = 2 }; var f3 = new Forum { ForumID = 3, CategoryID = 3 }; var cats = new List {c1, c2, c3}; var forums = new List {f1, f2, f3}; var container = new CategorizedForumContainer(cats, forums); Assert.True(container.CategoryDictionary.ToArray()[0].Key == c2); Assert.True(container.CategoryDictionary.ToArray()[1].Key == c3); Assert.True(container.CategoryDictionary.ToArray()[2].Key == c1); } [Fact] public void AllCollectionsPersist() { var c1 = new Category { CategoryID = 1 }; var c2 = new Category { CategoryID = 2 }; var f1 = new Forum { ForumID = 1, CategoryID = null }; var f2 = new Forum { ForumID = 2, CategoryID = 1 }; var f3 = new Forum { ForumID = 3, CategoryID = 2 }; var cats = new List { c1, c2 }; var forums = new List { f1, f2, f3 }; var container = new CategorizedForumContainer(cats, forums); Assert.Equal(cats, container.AllCategories); Assert.Equal(forums, container.AllForums); } [Fact] public void ForumsAppearInCategories() { var c1 = new Category { CategoryID = 1, Title = "Cat1" }; var c2 = new Category { CategoryID = 2, Title = "Cat2" }; var f1 = new Forum { ForumID = 1, CategoryID = null }; var f2 = new Forum { ForumID = 2, CategoryID = 1 }; var f3 = new Forum { ForumID = 3, CategoryID = 2 }; var cats = new List { c1, c2 }; var forums = new List { f1, f2, f3 }; var container = new CategorizedForumContainer(cats, forums); Assert.Contains(f2, container.CategoryDictionary[c1]); Assert.Contains(f3, container.CategoryDictionary[c2]); Assert.DoesNotContain(f1, container.CategoryDictionary[c1]); Assert.DoesNotContain(f3, container.CategoryDictionary[c1]); } [Fact] public void CategoryWithNoForumsDoesNotAppear() { var c1 = new Category { CategoryID = 1, Title = "Cat1" }; var c2 = new Category { CategoryID = 2, Title = "Cat2" }; var c3 = new Category { CategoryID = 3, Title = "Cat3" }; var f1 = new Forum { ForumID = 1, CategoryID = null }; var f2 = new Forum { ForumID = 2, CategoryID = 1 }; var f3 = new Forum { ForumID = 3, CategoryID = 2 }; var cats = new List { c1, c2, c3 }; var forums = new List { f1, f2, f3 }; var container = new CategorizedForumContainer(cats, forums); Assert.False(container.CategoryDictionary.ContainsKey(c3)); } } ================================================ FILE: src/PopForums.Test/Models/UserEditSecurityTests.cs ================================================ namespace PopForums.Test.Models; public class UserEditSecurityTests { [Fact] public void PasswordsMatch() { var edit = new UserEditSecurity(); edit.NewPassword = "blah"; edit.NewPasswordRetype = "blah"; Assert.True(edit.NewPasswordsMatch()); } [Fact] public void PasswordsNoMatch() { var edit = new UserEditSecurity(); edit.NewPassword = "blasjspvjsh"; edit.NewPasswordRetype = "blah"; Assert.False(edit.NewPasswordsMatch()); } [Fact] public void EmailMatch() { var edit = new UserEditSecurity(); edit.NewEmail = "blah"; edit.NewEmailRetype = "blah"; Assert.True(edit.NewEmailsMatch()); } [Fact] public void EmailNoMatch() { var edit = new UserEditSecurity(); edit.NewEmail = "blah"; edit.NewEmailRetype = "bloidsvosah"; Assert.False(edit.NewEmailsMatch()); } [Fact] public void IsNewUserApprovedMapped() { var edit = new UserEditSecurity(new User { UserID = 1 }, true); Assert.True(edit.IsNewUserApproved); } } ================================================ FILE: src/PopForums.Test/Models/UserTest.cs ================================================ namespace PopForums.Test.Models; public class UserTest { [Fact] public void IsRoleWiredToRoles() { var user = GetTestUser(); user.Roles = new List {"blah", "three", PermanentRoles.Admin}; Assert.True(user.IsInRole(PermanentRoles.Admin)); } public static User GetTestUser() { const int userID = 123; const string name = "Jeff"; const string email = "a@b.com"; var createDate = DateTime.UtcNow; const bool approved = true; var authKey = Guid.NewGuid(); return new User { UserID = userID, Name = name, Email = email, CreationDate = createDate, IsApproved = approved, AuthorizationKey = authKey, Roles = new List() }; } } ================================================ FILE: src/PopForums.Test/Mvc/Authorization/PopForumsPrivateForumsFilterTests.cs ================================================ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Routing; using PopForums.Mvc.Areas.Forums.Authorization; namespace PopForums.Test.Mvc.Authorization; public class PopForumsPrivateForumsFilterTests { private PopForumsPrivateForumsFilter GetFilter() { _userRetrievalShim = Substitute.For(); _settingsManager = Substitute.For(); _config = Substitute.For(); return new PopForumsPrivateForumsFilter(_userRetrievalShim, _settingsManager, _config); } private ActionExecutingContext GetContext() { return new ActionExecutingContext(new ActionContext{HttpContext = new DefaultHttpContext(), RouteData = new RouteData(), ActionDescriptor = new ActionDescriptor()}, new List(), new Dictionary(), null); } private IUserRetrievalShim _userRetrievalShim; private ISettingsManager _settingsManager; private IConfig _config; public class OnActionExecuting : PopForumsPrivateForumsFilterTests { [Fact] public void DoesNothingIfSettingIsFalseAndOAuthOnlyIsFalse() { var filter = GetFilter(); _config.IsOAuthOnly.Returns(false); _settingsManager.Current.IsPrivateForumInstance.Returns(false); var context = GetContext(); filter.OnActionExecuting(context); _userRetrievalShim.DidNotReceive().GetUser(); Assert.Null(context.Result); } [Fact] public void DoesNothingIfSettingIsTrueAndUserPresent() { var filter = GetFilter(); _config.IsOAuthOnly.Returns(false); _settingsManager.Current.IsPrivateForumInstance.Returns(true); var context = GetContext(); var user = new User(); _userRetrievalShim.GetUser().Returns(user); filter.OnActionExecuting(context); _userRetrievalShim.Received().GetUser(); Assert.Null(context.Result); } [Fact] public void DoesNothingIfOAuthOnlyIsTrueAndUserPresent() { var filter = GetFilter(); _config.IsOAuthOnly.Returns(true); _settingsManager.Current.IsPrivateForumInstance.Returns(false); var context = GetContext(); var user = new User(); _userRetrievalShim.GetUser().Returns(user); filter.OnActionExecuting(context); _userRetrievalShim.Received().GetUser(); Assert.Null(context.Result); } [Fact] public void RedirectIfSettingIsTrueAndNoUserPresent() { var filter = GetFilter(); _config.IsOAuthOnly.Returns(false); _settingsManager.Current.IsPrivateForumInstance.Returns(true); var context = GetContext(); filter.OnActionExecuting(context); _userRetrievalShim.Received().GetUser(); Assert.IsType(context.Result); } [Fact] public void RedirectIfOAuthOnlyIsTrueAndNoUserPresent() { var filter = GetFilter(); _config.IsOAuthOnly.Returns(true); _settingsManager.Current.IsPrivateForumInstance.Returns(false); var context = GetContext(); filter.OnActionExecuting(context); _userRetrievalShim.Received().GetUser(); Assert.IsType(context.Result); } } } ================================================ FILE: src/PopForums.Test/Mvc/Controllers/AccountControllerTests.cs ================================================ using System.Net; using Microsoft.AspNetCore.Http; using PopForums.Mvc.Areas.Forums.Models; using PopIdentity; namespace PopForums.Test.Mvc.Controllers; public class AccountControllerTests { private IUserService _userService; private IProfileService _profileService; private ISettingsManager _settingsManager; private INewAccountMailer _newAccountMailer; private IPostService _postService; private ITopicService _topicService; private IForumService _forumService; private ILastReadService _lastReadService; private IImageService _imageService; private IFeedService _feedService; private IUserAwardService _userAwardService; private IExternalUserAssociationManager _externalUserAssocManager; private IUserRetrievalShim _userRetrievalShim; private IExternalLoginRoutingService _externalLoginRoutingService; private IExternalLoginTempService _externalLoginTempService; private IConfig _config; private IReCaptchaService _recaptchaService; private IOAuthOnlyService _oAuthOnlyService; private AccountController GetController() { _userService = Substitute.For(); _profileService = Substitute.For(); _settingsManager = Substitute.For(); _newAccountMailer = Substitute.For(); _postService = Substitute.For(); _topicService = Substitute.For(); _forumService = Substitute.For(); _lastReadService = Substitute.For(); _imageService = Substitute.For(); _feedService = Substitute.For(); _userAwardService = Substitute.For(); _externalUserAssocManager = Substitute.For(); _userRetrievalShim = Substitute.For(); _externalLoginRoutingService = Substitute.For(); _externalLoginTempService = Substitute.For(); _config = Substitute.For(); _recaptchaService = Substitute.For(); _oAuthOnlyService = Substitute.For(); var controller = new AccountController(_userService, _profileService, _newAccountMailer, _settingsManager, _postService, _topicService, _forumService, _lastReadService, _imageService, _feedService, _userAwardService, _externalUserAssocManager, _userRetrievalShim, _externalLoginRoutingService, _externalLoginTempService, _config, _recaptchaService, _oAuthOnlyService); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; controller.HttpContext.Connection.RemoteIpAddress = IPAddress.Loopback; return controller; } public class Create : AccountControllerTests { [Fact] public void PopulatesDefaultValues() { var controller = GetController(); _settingsManager.Current.TermsOfService.Returns("tos"); var result = controller.Create(); Assert.Equal("tos", (result as ViewResult)?.ViewData[AccountController.TosKey]); } [Fact] public void PopulatesValuesFromExternalLogin() { var controller = GetController(); _settingsManager.Current.TermsOfService.Returns("tos"); var externalLoginState = new ExternalLoginState { ResultData = new ResultData { Email = "a@b.com", Name = "Diana" } }; _externalLoginTempService.Read().Returns(externalLoginState); var result = controller.Create(); var signupData = (SignupData)(result as ViewResult)?.Model; Assert.Equal("tos", (result as ViewResult)?.ViewData[AccountController.TosKey]); Assert.Equal(externalLoginState.ResultData.Email, signupData.Email); Assert.Equal(externalLoginState.ResultData.Name, signupData.Name); } } public class Verify : AccountControllerTests { [Fact] public async Task ReturnVerifyFailViewWhenNonGuidCode() { var controller = GetController(); var result = await controller.Verify("notaguid"); Assert.Equal("VerifyFail", result.ViewName); } [Fact] public async Task ReturnDefaultViewWhenNoCode() { var controller = GetController(); var result = await controller.Verify(""); Assert.Null(result.ViewName); } [Fact] public async Task ReturnVerifyFailViewWhenGuidMatchesNoUser() { var controller = GetController(); _userService.VerifyAuthorizationCode(Arg.Any(), Arg.Any()).Returns((User)null); var result = await controller.Verify("920A89D6-CE1B-4EBE-B758-50DB514B0ABF"); Assert.Equal("VerifyFail", result.ViewName); } [Fact] public async Task SuccessReturnViewWithMessage() { var controller = GetController(); var user = new User(); _userService.VerifyAuthorizationCode(Guid.Parse("920A89D6-CE1B-4EBE-B758-50DB514B0ABF"), Arg.Any()).Returns(Task.FromResult(user)); var result = await controller.Verify("920A89D6-CE1B-4EBE-B758-50DB514B0ABF"); Assert.Null(result.ViewName); Assert.Equal(Resources.AccountVerified, result.ViewData["Result"]); } [Fact] public async Task SuccessLoginUser() { var controller = GetController(); var user = new User(); _userService.VerifyAuthorizationCode(Guid.Parse("920A89D6-CE1B-4EBE-B758-50DB514B0ABF"), Arg.Any()).Returns(Task.FromResult(user)); var result = await controller.Verify("920A89D6-CE1B-4EBE-B758-50DB514B0ABF"); await _userService.Received().Login(user, Arg.Any()); } } public class VerifyCode : AccountControllerTests { [Fact] public void RedirectsToVerify() { var controller = GetController(); var code = "ED89EDB4-FBDD-494E-9ACF-2FE2AD69D21D"; var result = controller.VerifyCode(code); Assert.IsType(result); Assert.Equal("Verify", result.ActionName); Assert.Equal(code, result.RouteValues?["id"]); } } } ================================================ FILE: src/PopForums.Test/Mvc/Controllers/AdminApiControllerTests.cs ================================================ namespace PopForums.Test.Mvc.Controllers; public class AdminApiControllerTests { private ISettingsManager _settingsManager; private ICategoryService _categoryService; private IForumService _forumService; private IUserService _userService; private ISearchService _searchService; private IProfileService _profileService; private IUserRetrievalShim _userRetrievalShim; private IImageService _imageService; private IBanService _banService; private IMailingListService _mailingListService; private IEventDefinitionService _eventDefService; private IAwardDefinitionService _awardDefService; private IEventPublisher _eventPublisher; private IIPHistoryService _ipHistoryService; private ISecurityLogService _securityLogService; private IModerationLogService _moderationLogService; private IErrorLog _errorLog; private IServiceHeartbeatService _serviceHeartbeatService; private AdminApiController GetController() { _settingsManager = Substitute.For(); _categoryService = Substitute.For(); _forumService = Substitute.For(); _userService = Substitute.For(); _searchService = Substitute.For(); _profileService = Substitute.For(); _userRetrievalShim = Substitute.For(); _imageService = Substitute.For(); _banService = Substitute.For(); _mailingListService = Substitute.For(); _eventDefService = Substitute.For(); _awardDefService = Substitute.For(); _eventPublisher = Substitute.For(); _ipHistoryService = Substitute.For(); _securityLogService = Substitute.For(); _moderationLogService = Substitute.For(); _errorLog = Substitute.For(); _serviceHeartbeatService = Substitute.For(); return new AdminApiController(_settingsManager, _categoryService, _forumService, _userService, _searchService, _profileService, _userRetrievalShim, _imageService, _banService, _mailingListService, _eventDefService, _awardDefService, _eventPublisher, _ipHistoryService, _securityLogService, _moderationLogService, _errorLog, _serviceHeartbeatService); } public class SaveForum : AdminApiControllerTests { [Fact] public async Task CallsCreateIfForumIDIsZero() { var controller = GetController(); var forum = new Forum {ForumID = 0, CategoryID = 1, Title = "tt", Description = "dd", IsVisible = true, IsArchived = true, IsQAForum = true, ForumAdapterName = "ff"}; await controller.SaveForum(forum); await _forumService.Received().Create(forum.CategoryID, forum.Title, forum.Description, forum.IsVisible, forum.IsArchived, -1, forum.ForumAdapterName, forum.IsQAForum); await _forumService.DidNotReceive().Update(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task CallsUpdateIfForumIDIsNotZero() { var controller = GetController(); var forum = new Forum { ForumID = 123, CategoryID = 1, Title = "tt", Description = "dd", IsVisible = true, IsArchived = true, IsQAForum = true, ForumAdapterName = "ff" }; var retrievedForum = new Forum(); _forumService.Get(forum.ForumID).Returns(Task.FromResult(retrievedForum)); await controller.SaveForum(forum); await _forumService.Received().Update(retrievedForum, forum.CategoryID, forum.Title, forum.Description, forum.IsVisible, forum.IsArchived, forum.ForumAdapterName, forum.IsQAForum); await _forumService.DidNotReceive().Create(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task ReturnsNotFoundIfForumIsNotReal() { var controller = GetController(); _forumService.Get(Arg.Any()).Returns((Forum)null); var result = await controller.SaveForum(new Forum{ForumID = 123}); await _forumService.DidNotReceive().Update(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); await _forumService.DidNotReceive().Create(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); Assert.IsType(result.Result); } } public class GetForumPermissions : AdminApiControllerTests { [Fact] public async Task ContainerIsComposed() { var controller = GetController(); var forum = new Forum{ForumID = 123}; _forumService.Get(forum.ForumID).Returns(Task.FromResult(forum)); var all = new List {"a", "b"}; _userService.GetAllRoles().Returns(Task.FromResult(all)); var allView = new List {"c", "d"}; _forumService.GetForumViewRoles(forum).Returns(Task.FromResult(allView)); var allPost = new List {"e", "f"}; _forumService.GetForumPostRoles(forum).Returns(Task.FromResult(allPost)); var container = await controller.GetForumPermissions(forum.ForumID); Assert.Equal(forum.ForumID, container.Value.ForumID); Assert.Same(all, container.Value.AllRoles); Assert.Same(allView, container.Value.ViewRoles); Assert.Same(allPost, container.Value.PostRoles); } } public class EditUserSearch : AdminApiControllerTests { [Fact] public async Task NameSearchCallsNameSearch() { var controller = GetController(); var text = "abc"; var list = new List(); _userService.SearchByName(text).Returns(Task.FromResult(list)); var result = await controller.EditUserSearch(new UserSearch {SearchText = text, SearchType = UserSearch.UserSearchType.Name}); await _userService.Received().SearchByName(text); Assert.Same(list, result.Value); } [Fact] public async Task EmailSearchCallsEmailSearch() { var controller = GetController(); var text = "abc"; var list = new List(); _userService.SearchByEmail(text).Returns(Task.FromResult(list)); var result = await controller.EditUserSearch(new UserSearch { SearchText = text, SearchType = UserSearch.UserSearchType.Email }); await _userService.Received().SearchByEmail(text); Assert.Same(list, result.Value); } [Fact] public async Task RoleSearchCallsRoleSearch() { var controller = GetController(); var text = "abc"; var list = new List(); _userService.SearchByRole(text).Returns(Task.FromResult(list)); var result = await controller.EditUserSearch(new UserSearch { SearchText = text, SearchType = UserSearch.UserSearchType.Role }); await _userService.Received().SearchByRole(text); Assert.Same(list, result.Value); } } } ================================================ FILE: src/PopForums.Test/Mvc/Services/OAuthOnlyServiceTests.cs ================================================ using System.Security.Claims; using PopIdentity; using PopIdentity.Providers.OAuth2; namespace PopForums.Test.Mvc.Services; public class OAuthOnlyServiceTests { private IConfig _config; private IOAuth2LoginUrlGenerator _oAuth2LoginUrlGen; private IStateHashingService _stateHashingService; private IOAuth2JwtCallbackProcessor _oAuth2JwtCallbackProcessor; private IExternalUserAssociationManager _externalUserAssociationManager; private IUserService _userService; private IClaimsToRoleMapper _claimsToRoleMapper; private IUserNameReconciler _userNameReconciler; private IUserEmailReconciler _userEmailReconciler; private ISecurityLogService _securityLogService; private OAuthOnlyService GetService() { _config = Substitute.For(); _oAuth2LoginUrlGen = Substitute.For(); _stateHashingService = Substitute.For(); _oAuth2JwtCallbackProcessor = Substitute.For(); _externalUserAssociationManager = Substitute.For(); _userService = Substitute.For(); _claimsToRoleMapper = Substitute.For(); _userNameReconciler = Substitute.For(); _userEmailReconciler = Substitute.For(); _securityLogService = Substitute.For(); return new OAuthOnlyService(_config, _oAuth2LoginUrlGen, _stateHashingService, _oAuth2JwtCallbackProcessor, _externalUserAssociationManager, _userService, _claimsToRoleMapper, _userNameReconciler, _userEmailReconciler, _securityLogService); } public class GetLoginUrl : OAuthOnlyServiceTests { [Fact] public void ConfigParameterAndHashValuesCalledToLoginGen() { var service = GetService(); _config.OAuthLoginBaseUrl.Returns("baseUrl"); _config.OAuthClientID.Returns("clientID"); var redirectUrl = "the redirect url"; var code = "hash"; _stateHashingService.SetCookieAndReturnHash().Returns(code); _config.OAuthScopes.Returns("openid email profile"); service.GetLoginUrl(redirectUrl); _oAuth2LoginUrlGen.Received().GetUrl(_config.OAuthLoginBaseUrl, _config.OAuthClientID, redirectUrl, code, _config.OAuthScopes); } [Fact] public void GeneratedUrlReturned() { var service = GetService(); var url = "the return url"; _oAuth2LoginUrlGen.GetUrl(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(url); var result = service.GetLoginUrl("whatever"); Assert.Equal(url, result); } } public class ProcessOAuthLogin : OAuthOnlyServiceTests { [Fact] public async Task FailedCallbackReturnsFail() { var service = GetService(); var callbackResult = new CallbackResult { IsSuccessful = false }; _oAuth2JwtCallbackProcessor.VerifyCallback(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(callbackResult)); var result = await service.ProcessOAuthLogin("url", "ip"); Assert.False(result.IsSuccessful); } [Fact] public async Task CallbackValuesMappedToExternalCheck() { var service = GetService(); var callbackResult = new CallbackResult { IsSuccessful = true, Claims = new List(), ResultData = new ResultData{Email = "e", ID = "i", Name = "n"}}; _oAuth2JwtCallbackProcessor.VerifyCallback(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(callbackResult)); var externalUserMatch = new ExternalUserAssociationMatchResult { User = new User(), Successful = true }; ExternalLoginInfo calledExternalInfo = null; _externalUserAssociationManager .ExternalUserAssociationCheck(Arg.Do(x => calledExternalInfo = x), Arg.Any()) .Returns(Task.FromResult(externalUserMatch)); _config.OAuthRefreshExpirationMinutes.Returns(60); var result = await service.ProcessOAuthLogin("url", "ip"); Assert.Equal(callbackResult.ResultData.ID, calledExternalInfo.ProviderKey); Assert.Equal(callbackResult.ResultData.Name, calledExternalInfo.ProviderDisplayName); Assert.Equal(ProviderType.OAuthOnly.ToString(), calledExternalInfo.LoginProvider); } [Fact] public async Task ExistingUserMappedToClaims() { var service = GetService(); var claims = new Claim[] { new("name", "value") }; var callbackResult = new CallbackResult { IsSuccessful = true, Claims = claims, ResultData = new ResultData{Email = "e", ID = "i", Name = "n"}}; _oAuth2JwtCallbackProcessor.VerifyCallback(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(callbackResult)); var externalUserMatch = new ExternalUserAssociationMatchResult { User = new User(), Successful = true }; _externalUserAssociationManager .ExternalUserAssociationCheck(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(externalUserMatch)); _config.OAuthRefreshExpirationMinutes.Returns(60); var result = await service.ProcessOAuthLogin("url", "ip"); await _claimsToRoleMapper.Received().MapRoles(externalUserMatch.User, claims); } [Fact] public async Task ExistingUserNameUnchangedNotChanged() { var service = GetService(); var user = new User { Name = "Simon" }; var callbackResult = new CallbackResult { IsSuccessful = true, Claims = new Claim[]{}, ResultData = new ResultData{Email = "e", ID = "i", Name = user.Name}}; _oAuth2JwtCallbackProcessor.VerifyCallback(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(callbackResult)); var externalUserMatch = new ExternalUserAssociationMatchResult { User = user, Successful = true }; _externalUserAssociationManager .ExternalUserAssociationCheck(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(externalUserMatch)); _userNameReconciler.GetUniqueNameForUser(Arg.Any()).Returns(Task.FromResult(user.Name)); var result = await service.ProcessOAuthLogin("url", "ip"); await _userService.DidNotReceive().ChangeName(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task ExistingUserNameChangedIsChanged() { var service = GetService(); var user = new User { Name = "Simon" }; var callbackResult = new CallbackResult { IsSuccessful = true, Claims = new Claim[]{}, ResultData = new ResultData{Email = "e", ID = "i", Name = "Jeff"}}; _oAuth2JwtCallbackProcessor.VerifyCallback(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(callbackResult)); var externalUserMatch = new ExternalUserAssociationMatchResult { User = user, Successful = true }; _externalUserAssociationManager .ExternalUserAssociationCheck(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(externalUserMatch)); _userNameReconciler.GetUniqueNameForUser(Arg.Any()).Returns(Task.FromResult("unique")); var result = await service.ProcessOAuthLogin("url", "ip"); await _userService.Received().ChangeName(user, "unique", Arg.Any(), Arg.Any()); } [Fact] public async Task NewUserMappedToClaims() { var service = GetService(); var claims = new Claim[] { new("name", "value") }; var callbackResult = new CallbackResult { IsSuccessful = true, Claims = claims, ResultData = new ResultData{Email = "e", ID = "i", Name = "n"}}; _oAuth2JwtCallbackProcessor.VerifyCallback(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(callbackResult)); var externalUserMatch = new ExternalUserAssociationMatchResult { Successful = false }; _externalUserAssociationManager .ExternalUserAssociationCheck(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(externalUserMatch)); var user = new User(); _userService.CreateUserWithProfile(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(user)); _config.OAuthRefreshExpirationMinutes.Returns(60); var result = await service.ProcessOAuthLogin("url", "ip"); await _claimsToRoleMapper.Received().MapRoles(user, claims); } [Fact] public async Task UnmatchedUserIsCreatedAndAssociated() { var service = GetService(); var redirUrl = "redir"; var ip = "127.0.0.1"; var callbackResult = new CallbackResult { ResultData = new ResultData { Email = "a@b.com", ID = "id", Name = "Diana" }, IsSuccessful = true }; _config.OAuthTokenUrl.Returns("t"); _config.OAuthClientID.Returns("c"); _config.OAuthClientSecret.Returns("s"); _oAuth2JwtCallbackProcessor.VerifyCallback(redirUrl, "t", "c", "s").Returns(Task.FromResult(callbackResult)); ExternalLoginInfo externalLoginInfo = null; _externalUserAssociationManager .ExternalUserAssociationCheck(Arg.Do(x => externalLoginInfo = x), ip) .Returns(Task.FromResult(new ExternalUserAssociationMatchResult { Successful = false })); var user = new User(); SignupData signupData = null; _userService.CreateUserWithProfile(Arg.Do(x => signupData = x), ip).Returns(Task.FromResult(user)); _userNameReconciler.GetUniqueNameForUser("Diana").Returns(Task.FromResult("UniqueD")); _userEmailReconciler.GetUniqueEmail("a@b.com", "id").Returns(Task.FromResult("uid")); await service.ProcessOAuthLogin(redirUrl, ip); Assert.Equal(callbackResult.ResultData.ID, externalLoginInfo.ProviderKey); Assert.Equal("UniqueD", signupData.Name); Assert.Equal("uid", signupData.Email); await _userService.Received().CreateUserWithProfile(signupData, ip); await _externalUserAssociationManager.Received().Associate(user, externalLoginInfo, ip); } } public class AttemptTokenRefresh : OAuthOnlyServiceTests { [Fact] public async Task FailedCallbackMakesNoUpdates() { var service = GetService(); _oAuth2JwtCallbackProcessor.GetRefreshToken(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(Task.FromResult(new CallbackResult { IsSuccessful = false })); await service.AttemptTokenRefresh(default); await _userService.DidNotReceive().UpdateTokenExpiration(Arg.Any(), Arg.Any()); await _userService.DidNotReceive().UpdateRefreshToken(Arg.Any(), Arg.Any()); } [Fact] public async Task GoodTokenUpdatesStuff() { var service = GetService(); var user = new User(); var token = "token"; var newToken = "newtoken"; _userService.GetRefreshToken(user).Returns(Task.FromResult(token)); _config.OAuthTokenUrl.Returns("u"); _config.OAuthClientID.Returns("c"); _config.OAuthClientSecret.Returns("s"); _oAuth2JwtCallbackProcessor.GetRefreshToken(token, "u", "c", "s").Returns(Task.FromResult(new CallbackResult { IsSuccessful = true, RefreshToken = newToken })); await service.AttemptTokenRefresh(user); await _oAuth2JwtCallbackProcessor.Received().GetRefreshToken(token, "u", "c", "s"); await _userService.Received().UpdateTokenExpiration(user, Arg.Any()); await _userService.Received().UpdateRefreshToken(user, newToken); } } } ================================================ FILE: src/PopForums.Test/PopForums.Test.csproj ================================================  22.0.0 net10.0 PopForums.Test PopForums.Test true all runtime; build; native; contentfiles; analyzers; buildtransitive all runtime; build; native; contentfiles; analyzers; buildtransitive ================================================ FILE: src/PopForums.Test/ScoringGame/AwardCalculatorTests.cs ================================================ namespace PopForums.Test.ScoringGame; public class AwardCalculatorTests { private AwardCalculator GetCalc() { _awardCalcRepo = Substitute.For(); _eventDefService = Substitute.For(); _userRepo = Substitute.For(); _errorLog = Substitute.For(); _awardDefService = Substitute.For(); _userAwardService = Substitute.For(); _pointLedgerRepo = Substitute.For(); _tenantService = Substitute.For(); return new AwardCalculator(_awardCalcRepo, _eventDefService, _userRepo, _errorLog, _awardDefService, _userAwardService, _pointLedgerRepo, _tenantService); } private IAwardCalculationQueueRepository _awardCalcRepo; private IEventDefinitionService _eventDefService; private IUserRepository _userRepo; private IErrorLog _errorLog; private IAwardDefinitionService _awardDefService; private IUserAwardService _userAwardService; private IPointLedgerRepository _pointLedgerRepo; private ITenantService _tenantService; [Fact] public async Task EnqueueDoesWhatItSaysItShould() { var calc = GetCalc(); var user = new User(); var eventDef = new EventDefinition {EventDefinitionID = "blah"}; var tenantID = "t1"; _tenantService.GetTenant().Returns(tenantID); var payload = new AwardCalculationPayload(); _awardCalcRepo.Enqueue(Arg.Do(x => payload = x)).Returns(Task.CompletedTask); await calc.QueueCalculation(user, eventDef); await _awardCalcRepo.Received().Enqueue(Arg.Any()); Assert.Equal(tenantID, payload.TenantID); Assert.Equal(eventDef.EventDefinitionID, payload.EventDefinitionID); } [Fact] public async Task ProcessLogsAndDoesNothingWithNullEventDef() { var calc = GetCalc(); var user = new User(); _eventDefService.GetEventDefinition(Arg.Any()).Returns((EventDefinition) null); _userRepo.GetUser(Arg.Any()).Returns(Task.FromResult(user)); _awardCalcRepo.Dequeue().Returns(Task.FromResult(new KeyValuePair("oih", user.UserID))); await calc.ProcessCalculation(null, 0); _errorLog.Received().Log(Arg.Any(), ErrorSeverity.Warning); await _userAwardService.DidNotReceive().IssueAward(Arg.Any(), Arg.Any()); } [Fact] public async Task ProcessNeverCallsIssueIfAwardedAndSingleAward() { var calc = GetCalc(); var eventDef = new EventDefinition {EventDefinitionID = "oi"}; var user = new User(); var awardDef = new AwardDefinition {AwardDefinitionID = "sweet", IsSingleTimeAward = true}; _awardCalcRepo.Dequeue().Returns(Task.FromResult(new KeyValuePair(eventDef.EventDefinitionID, user.UserID))); _eventDefService.GetEventDefinition(Arg.Any()).Returns(Task.FromResult(eventDef)); _userRepo.GetUser(Arg.Any()).Returns(Task.FromResult(user)); _awardDefService.GetByEventDefinitionID(eventDef.EventDefinitionID).Returns(Task.FromResult(new List {awardDef})); _userAwardService.IsAwarded(user, awardDef).Returns(Task.FromResult(true)); await calc.ProcessCalculation(eventDef.EventDefinitionID, user.UserID); await _userAwardService.DidNotReceive().IssueAward(Arg.Any(), Arg.Any()); } [Fact] public async Task ProcessNeverCallsIfEventCountNotHighEnough() { var calc = GetCalc(); var eventDef = new EventDefinition { EventDefinitionID = "oi" }; var user = new User { UserID = 1 }; var awardDef = new AwardDefinition {AwardDefinitionID = "sweet", IsSingleTimeAward = true}; var conditions = new List { new AwardCondition { AwardDefinitionID = awardDef.AwardDefinitionID, EventDefinitionID ="qwerty", EventCount = 3}, new AwardCondition { AwardDefinitionID = awardDef.AwardDefinitionID, EventDefinitionID ="asdfgh", EventCount = 5} }; _awardCalcRepo.Dequeue().Returns(Task.FromResult(new KeyValuePair(eventDef.EventDefinitionID, user.UserID))); _eventDefService.GetEventDefinition(Arg.Any()).Returns(Task.FromResult(eventDef)); _userRepo.GetUser(Arg.Any()).Returns(Task.FromResult(user)); _awardDefService.GetByEventDefinitionID(eventDef.EventDefinitionID).Returns(Task.FromResult(new List { awardDef })); _userAwardService.IsAwarded(user, awardDef).Returns(Task.FromResult(false)); _awardDefService.GetConditions(awardDef.AwardDefinitionID).Returns(Task.FromResult(conditions)); _pointLedgerRepo.GetEntryCount(user.UserID, conditions[0].EventDefinitionID).Returns(Task.FromResult(10)); _pointLedgerRepo.GetEntryCount(user.UserID, conditions[1].EventDefinitionID).Returns(Task.FromResult(4)); await calc.ProcessCalculation(eventDef.EventDefinitionID, user.UserID); await _userAwardService.DidNotReceive().IssueAward(Arg.Any(), Arg.Any()); } [Fact] public async Task ProcessIssuesAwardWhenConditionsEqualOrGreater() { var calc = GetCalc(); var eventDef = new EventDefinition { EventDefinitionID = "oi" }; var user = new User { UserID = 1 }; var awardDef = new AwardDefinition { AwardDefinitionID = "sweet", IsSingleTimeAward = true }; var conditions = new List { new AwardCondition { AwardDefinitionID = awardDef.AwardDefinitionID, EventDefinitionID ="qwerty", EventCount = 3}, new AwardCondition { AwardDefinitionID = awardDef.AwardDefinitionID, EventDefinitionID ="asdfgh", EventCount = 5} }; _awardCalcRepo.Dequeue().Returns(Task.FromResult(new KeyValuePair(eventDef.EventDefinitionID, user.UserID))); _eventDefService.GetEventDefinition(Arg.Any()).Returns(Task.FromResult(eventDef)); _userRepo.GetUser(Arg.Any()).Returns(Task.FromResult(user)); _awardDefService.GetByEventDefinitionID(eventDef.EventDefinitionID).Returns(Task.FromResult(new List { awardDef })); _userAwardService.IsAwarded(user, awardDef).Returns(Task.FromResult(false)); _awardDefService.GetConditions(awardDef.AwardDefinitionID).Returns(Task.FromResult(conditions)); _pointLedgerRepo.GetEntryCount(user.UserID, conditions[0].EventDefinitionID).Returns(Task.FromResult(10)); _pointLedgerRepo.GetEntryCount(user.UserID, conditions[1].EventDefinitionID).Returns(Task.FromResult(5)); await calc.ProcessCalculation(eventDef.EventDefinitionID, user.UserID); await _userAwardService.Received().IssueAward(Arg.Any(), Arg.Any()); } [Fact] public async Task ProcessIssuesSecondAwardWhenConditionsEqualOrGreater() { var calc = GetCalc(); var eventDef = new EventDefinition { EventDefinitionID = "oi" }; var user = new User { UserID = 1 }; var firstAwardDef = new AwardDefinition {AwardDefinitionID = "first", IsSingleTimeAward = true}; var secondAwardDef = new AwardDefinition { AwardDefinitionID = "sweet", IsSingleTimeAward = true }; var conditions = new List { new AwardCondition { AwardDefinitionID = secondAwardDef.AwardDefinitionID, EventDefinitionID ="qwerty", EventCount = 3}, new AwardCondition { AwardDefinitionID = secondAwardDef.AwardDefinitionID, EventDefinitionID ="asdfgh", EventCount = 5} }; _awardCalcRepo.Dequeue().Returns(Task.FromResult(new KeyValuePair(eventDef.EventDefinitionID, user.UserID))); _eventDefService.GetEventDefinition(Arg.Any()).Returns(Task.FromResult(eventDef)); _userRepo.GetUser(Arg.Any()).Returns(Task.FromResult(user)); _awardDefService.GetByEventDefinitionID(eventDef.EventDefinitionID).Returns(Task.FromResult(new List { firstAwardDef, secondAwardDef })); _userAwardService.IsAwarded(user, secondAwardDef).Returns(Task.FromResult(false)); _awardDefService.GetConditions(firstAwardDef.AwardDefinitionID).Returns(Task.FromResult(new List())); _awardDefService.GetConditions(secondAwardDef.AwardDefinitionID).Returns(Task.FromResult(conditions)); _pointLedgerRepo.GetEntryCount(user.UserID, conditions[0].EventDefinitionID).Returns(Task.FromResult(10)); _pointLedgerRepo.GetEntryCount(user.UserID, conditions[1].EventDefinitionID).Returns(Task.FromResult(5)); await calc.ProcessCalculation(eventDef.EventDefinitionID, user.UserID); await _userAwardService.Received().IssueAward(Arg.Any(), Arg.Any()); } } ================================================ FILE: src/PopForums.Test/ScoringGame/AwardCalculatorWorkerTests.cs ================================================ using NSubstitute.ExceptionExtensions; namespace PopForums.Test.ScoringGame; public class AwardCalculatorWorkerTests { private IAwardCalculator _calculator; private IAwardCalculationQueueRepository _awardCalculationQueueRepository; private IErrorLog _errorLog; private IAwardCalculatorWorker GetWorker() { _calculator = Substitute.For(); _awardCalculationQueueRepository = Substitute.For(); _errorLog = Substitute.For(); return new AwardCalculatorWorker(_calculator, _awardCalculationQueueRepository, _errorLog); } [Fact] public void DoNothingWhenNoPayload() { var worker = GetWorker(); _awardCalculationQueueRepository.Dequeue().Returns(new KeyValuePair (null, 1)); worker.Execute(); _calculator.DidNotReceiveWithAnyArgs().ProcessCalculation(Arg.Any(), Arg.Any()); _errorLog.DidNotReceiveWithAnyArgs().Log(Arg.Any(), Arg.Any()); } [Fact] public void CallProcessPayloadWithPayloadValues() { var worker = GetWorker(); _awardCalculationQueueRepository.Dequeue().Returns(new KeyValuePair ("key", 1)); worker.Execute(); _calculator.Received().ProcessCalculation("key", 1); _errorLog.DidNotReceiveWithAnyArgs().Log(Arg.Any(), Arg.Any()); } [Fact] public void LogWhenDequeueThrows() { var worker = GetWorker(); _awardCalculationQueueRepository.Dequeue().ThrowsAsync(new Exception()); worker.Execute(); _calculator.DidNotReceiveWithAnyArgs().ProcessCalculation(Arg.Any(), Arg.Any()); _errorLog.Received().Log(Arg.Any(), ErrorSeverity.Error); } [Fact] public void LogWhenProcessCalculationThrows() { var worker = GetWorker(); _awardCalculationQueueRepository.Dequeue().Returns(new KeyValuePair ("key", 1)); _calculator.When(x => x.ProcessCalculation("key", 1)).Throw(new Exception()); worker.Execute(); _errorLog.Received().Log(Arg.Any(), ErrorSeverity.Error); } } ================================================ FILE: src/PopForums.Test/ScoringGame/AwardDefinitionServiceTests.cs ================================================ namespace PopForums.Test.ScoringGame; public class AwardDefinitionServiceTests { public AwardDefinitionService GetService() { _awardDefRepo = Substitute.For(); _awardConditionRepo = Substitute.For(); return new AwardDefinitionService(_awardDefRepo, _awardConditionRepo); } private IAwardDefinitionRepository _awardDefRepo; private IAwardConditionRepository _awardConditionRepo; [Fact] public void CreateMapsObjectToRepo() { var awardDef = new AwardDefinition {AwardDefinitionID = "blah", Title = "title", Description = "desc", IsSingleTimeAward = true}; var service = GetService(); service.Create(awardDef); _awardDefRepo.Received().Create(awardDef.AwardDefinitionID, awardDef.Title, awardDef.Description, awardDef.IsSingleTimeAward); } [Fact] public async Task SaveConditionsDeletesOldOnes() { var awardDef = new AwardDefinition {AwardDefinitionID = "awarddef"}; var service = GetService(); await service.SaveConditions(awardDef, new List()); await _awardConditionRepo.Received().DeleteConditions(awardDef.AwardDefinitionID); } [Fact] public async Task SaveConditionsSetsAllAwardDefIDs() { var awardDef = new AwardDefinition { AwardDefinitionID = "awarddef" }; var list = new List { new AwardCondition { AwardDefinitionID = "bad" }, new AwardCondition { AwardDefinitionID = "toobad" } }; var savingList = new List(); var service = GetService(); await _awardConditionRepo.SaveConditions(Arg.Do>(x => savingList = x)); await service.SaveConditions(awardDef, list); Assert.Equal(savingList[0].AwardDefinitionID, awardDef.AwardDefinitionID); Assert.Equal(savingList[1].AwardDefinitionID, awardDef.AwardDefinitionID); } } ================================================ FILE: src/PopForums.Test/ScoringGame/EventDefintionServiceTests.cs ================================================ namespace PopForums.Test.ScoringGame; public class EventDefintionServiceTests { private EventDefinitionService GetService() { _eventDefRepo = Substitute.For(); _awardConditionRepo = Substitute.For(); return new EventDefinitionService(_eventDefRepo, _awardConditionRepo); } private IEventDefinitionRepository _eventDefRepo; private IAwardConditionRepository _awardConditionRepo; [Fact] public async Task GetReturnsFromRepo() { var service = GetService(); var def = new EventDefinition {EventDefinitionID = "whatevs", PointValue = 2, Description = "stuff"}; _eventDefRepo.Get(def.EventDefinitionID).Returns(Task.FromResult(def)); var result = await service.GetEventDefinition(def.EventDefinitionID); Assert.Same(def, result); } [Fact] public async Task GetStaticPostVoteReturnsStaticObject() { var service = GetService(); var result = await service.GetEventDefinition(EventDefinitionService.StaticEventIDs.PostVote); Assert.Same(EventDefinitionService.StaticEvents[EventDefinitionService.StaticEventIDs.PostVote], result); } [Fact] public async Task GetAllMergesStaticWithRepo() { var service = GetService(); var list = new List {new() {EventDefinitionID = "AAA"}, new() {EventDefinitionID = "ZZZ"}}; _eventDefRepo.GetAll().Returns(Task.FromResult(list)); var result = await service.GetAll(); Assert.Equal(7, result.Count); Assert.True(result.Count(x => x.EventDefinitionID == "AAA") == 1); Assert.True(result.Count(x => x.EventDefinitionID == "ZZZ") == 1); Assert.True(result.Count(x => x.EventDefinitionID == EventDefinitionService.StaticEventIDs.NewPost) == 1); Assert.True(result.Count(x => x.EventDefinitionID == EventDefinitionService.StaticEventIDs.NewTopic) == 1); Assert.True(result.Count(x => x.EventDefinitionID == EventDefinitionService.StaticEventIDs.PostVote) == 1); } [Fact] public async Task GetAllMergesAndOrders() { var service = GetService(); var list = new List { new() { EventDefinitionID = "AAA" }, new() { EventDefinitionID = "ZZZ" } }; _eventDefRepo.GetAll().Returns(Task.FromResult(list)); var result = await service.GetAll(); Assert.Equal(7, result.Count); Assert.Equal("AAA", result[0].EventDefinitionID); Assert.Equal(EventDefinitionService.StaticEventIDs.NewPost, result[1].EventDefinitionID); Assert.Equal(EventDefinitionService.StaticEventIDs.NewTopic, result[2].EventDefinitionID); Assert.Equal(EventDefinitionService.StaticEventIDs.PostVote, result[3].EventDefinitionID); Assert.Equal(EventDefinitionService.StaticEventIDs.PostVoteUndo, result[4].EventDefinitionID); Assert.Equal("ZZZ", result[6].EventDefinitionID); } [Fact] public async Task CreatePassesToRepo() { var service = GetService(); var eventDef = new EventDefinition(); await service.Create(eventDef); await _eventDefRepo.Received().Create(eventDef); } [Fact] public async Task DeleteCallsEventDefRepoAndAwardConditionRepo() { var service = GetService(); const string eventDefID = "ohnoes!"; await service.Delete(eventDefID); _eventDefRepo.Received().Delete(eventDefID); await _awardConditionRepo.Received().DeleteConditionsByEventDefinitionID(eventDefID); } } ================================================ FILE: src/PopForums.Test/ScoringGame/EventPublisherTests.cs ================================================ namespace PopForums.Test.ScoringGame; public class EventPublisherTests { private EventPublisher GetPublisher() { _eventDefService = Substitute.For(); _pointLedgerRepo = Substitute.For(); _feedService = Substitute.For(); _awardCalc = Substitute.For(); _profileService = Substitute.For(); return new EventPublisher(_eventDefService, _pointLedgerRepo, _feedService, _awardCalc, _profileService); } private IEventDefinitionService _eventDefService; private IPointLedgerRepository _pointLedgerRepo; private IFeedService _feedService; private IAwardCalculator _awardCalc; private IProfileService _profileService; [Fact] public async Task ProcessEventPublishesToLedger() { var user = new User { UserID = 123 }; var eventDef = new EventDefinition {EventDefinitionID = "blah", PointValue = 42}; const string message = "msg"; var publisher = GetPublisher(); _eventDefService.GetEventDefinition(eventDef.EventDefinitionID).Returns(Task.FromResult(eventDef)); var entry = new PointLedgerEntry(); await _pointLedgerRepo.RecordEntry(Arg.Do(x => entry = x)); await publisher.ProcessEvent(message, user, eventDef.EventDefinitionID, false); Assert.Equal(user.UserID, entry.UserID); Assert.Equal(eventDef.EventDefinitionID, entry.EventDefinitionID); Assert.Equal(eventDef.PointValue, entry.Points); } [Fact] public async Task ProcessEventPublishesToFeedService() { var user = new User { UserID = 123 }; var eventDef = new EventDefinition { EventDefinitionID = "blah", PointValue = 42, IsPublishedToFeed = true }; const string message = "msg"; var publisher = GetPublisher(); _eventDefService.GetEventDefinition(eventDef.EventDefinitionID).Returns(Task.FromResult(eventDef)); await publisher.ProcessEvent(message, user, eventDef.EventDefinitionID, false); await _feedService.Received().PublishToFeed(user, message, eventDef.PointValue, Arg.Any()); } [Fact] public async Task ProcessEventDoesNotPublishToFeedServiceWhenEventDefSaysNo() { var user = new User { UserID = 123 }; var eventDef = new EventDefinition { EventDefinitionID = "blah", PointValue = 42, IsPublishedToFeed = false }; const string message = "msg"; var publisher = GetPublisher(); _eventDefService.GetEventDefinition(eventDef.EventDefinitionID).Returns(Task.FromResult(eventDef)); await publisher.ProcessEvent(message, user, eventDef.EventDefinitionID, false); await _feedService.DidNotReceive().PublishToFeed(user, message, eventDef.PointValue, Arg.Any()); } [Fact] public async Task ProcessEventCallsCalculator() { var user = new User { UserID = 123 }; var eventDef = new EventDefinition { EventDefinitionID = "blah", PointValue = 42 }; var publisher = GetPublisher(); _eventDefService.GetEventDefinition(eventDef.EventDefinitionID).Returns(Task.FromResult(eventDef)); await publisher.ProcessEvent("msg", user, eventDef.EventDefinitionID, false); await _awardCalc.Received().QueueCalculation(user, eventDef); } [Fact] public async Task ProcessEventUpdatesProfilePointTotal() { var user = new User { UserID = 123 }; var eventDef = new EventDefinition { EventDefinitionID = "blah", PointValue = 42 }; var publisher = GetPublisher(); _eventDefService.GetEventDefinition(eventDef.EventDefinitionID).Returns(Task.FromResult(eventDef)); await publisher.ProcessEvent("msg", user, eventDef.EventDefinitionID, false); await _profileService.Received().UpdatePointTotal(user); } [Fact] public async Task ProcessManualEventPublishesToLedger() { var user = new User { UserID = 123 }; const string message = "msg"; const int points = 252; var publisher = GetPublisher(); var entry = new PointLedgerEntry(); await _pointLedgerRepo.RecordEntry(Arg.Do(x => entry = x)); await publisher.ProcessManualEvent(message, user, points); Assert.Equal(user.UserID, entry.UserID); Assert.Equal("Manual", entry.EventDefinitionID); Assert.Equal(points, entry.Points); } [Fact] public async Task ProcessManualEventPublishesToFeedService() { var user = new User { UserID = 123 }; const string message = "msg"; const int points = 252; var publisher = GetPublisher(); await publisher.ProcessManualEvent(message, user, points); await _feedService.Received().PublishToFeed(user, message, points, Arg.Any()); } [Fact] public async Task ProcessManualEventUpdatesProfilePointTotal() { var user = new User { UserID = 123 }; var publisher = GetPublisher(); await publisher.ProcessManualEvent("msg", user, 252); await _profileService.Received().UpdatePointTotal(user); } } ================================================ FILE: src/PopForums.Test/ScoringGame/FeedServiceTests.cs ================================================ namespace PopForums.Test.ScoringGame; public class FeedServiceTests { private FeedService GetService() { _feedRepo = Substitute.For(); _broker = Substitute.For(); return new FeedService(_feedRepo, _broker); } private IFeedRepository _feedRepo; private IBroker _broker; [Fact] public async Task PublishSavesToRepo() { var service = GetService(); var user = new User { UserID = 123 }; const string msg = "oiehgfoih"; const int points = 5352; var timeStamp = new DateTime(2000, 1, 1); await service.PublishToFeed(user, msg, points, timeStamp); await _feedRepo.Received().PublishEvent(user.UserID, msg, points, timeStamp); } [Fact] public async Task PublishDeletesOlderThan50() { var service = GetService(); var user = new User { UserID = 123 }; var timeStamp = new DateTime(2000, 1, 1); var cutOff = new DateTime(1999, 2, 2); const int points = 5352; _feedRepo.GetOldestTime(user.UserID, 50).Returns(Task.FromResult(cutOff)); await service.PublishToFeed(user, "whatevs", points, timeStamp); await _feedRepo.Received().DeleteOlderThan(user.UserID, cutOff); } [Fact] public async Task PublishDoesNothingIfUserIsNull() { var service = GetService(); await service.PublishToFeed(null, String.Empty, 423, new DateTime()); await _feedRepo.DidNotReceive().PublishEvent(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task GetFeedGets50ItemsMaxFromRepo() { var service = GetService(); var user = new User { UserID = 123 }; var list = new List(); _feedRepo.GetFeed(user.UserID, 50).Returns(Task.FromResult(list)); var result = await service.GetFeed(user); Assert.Same(result, list); } } ================================================ FILE: src/PopForums.Test/ScoringGame/UserAwardServiceTests.cs ================================================ namespace PopForums.Test.ScoringGame; public class UserAwardServiceTests { public UserAwardService GetService() { _userAwardRepo = Substitute.For(); _notificationTunnel = Substitute.For(); _tenantService = Substitute.For(); return new UserAwardService(_userAwardRepo, _notificationTunnel, _tenantService); } private IUserAwardRepository _userAwardRepo; private INotificationTunnel _notificationTunnel; private ITenantService _tenantService; [Fact] public async Task IssueMapsFieldsToRepoCall() { var user = new User { UserID = 123 }; var awardDef = new AwardDefinition {AwardDefinitionID = "blah", Description = "desc", Title = "title", IsSingleTimeAward = true}; var service = GetService(); await service.IssueAward(user, awardDef); await _userAwardRepo.Received().IssueAward(user.UserID, awardDef.AwardDefinitionID, awardDef.Title, awardDef.Description, Arg.Any()); } [Fact] public async Task IsAwardedMapsAndReturnsRightValue() { var user = new User { UserID = 123 }; var awardDef = new AwardDefinition { AwardDefinitionID = "blah" }; var service = GetService(); _userAwardRepo.IsAwarded(user.UserID, awardDef.AwardDefinitionID).Returns(Task.FromResult(true)); var result = await service.IsAwarded(user, awardDef); Assert.True(result); } [Fact] public async Task GetAwardsMapsUserIDAndReturnsList() { var user = new User { UserID = 123 }; var list = new List(); var service = GetService(); _userAwardRepo.GetAwards(user.UserID).Returns(Task.FromResult(list)); var result = await service.GetAwards(user); Assert.Same(list, result); } } ================================================ FILE: src/PopForums.Test/Services/BanServiceTests.cs ================================================ namespace PopForums.Test.Services; public class BanServiceTests { private IBanRepository _banRepo; private IBanService GetService() { _banRepo = Substitute.For(); return new BanService(_banRepo); } [Fact] public async Task IPTrimmedOnSave() { var service = GetService(); await service.BanIP(" 1.1.1.1 "); await _banRepo.Received().BanIP("1.1.1.1"); } [Fact] public async Task EmailTrimmedOnSave() { var service = GetService(); await service.BanEmail(" a@b.com "); await _banRepo.Received().BanEmail("a@b.com"); } } ================================================ FILE: src/PopForums.Test/Services/CategoryServiceTests.cs ================================================ namespace PopForums.Test.Services; public class CategoryServiceTests { private ICategoryRepository _mockCategoryRepo; private IForumRepository _mockForumRepo; private ICategoryService GetService() { _mockCategoryRepo = Substitute.For(); _mockForumRepo = Substitute.For(); var service = new CategoryService(_mockCategoryRepo, _mockForumRepo); return service; } [Fact] public async Task GetAll() { var service = GetService(); var allCats = new List(); _mockCategoryRepo.GetAll().Returns(Task.FromResult(allCats)); var result = await service.GetAll(); Assert.Same(allCats, result); await _mockCategoryRepo.Received().GetAll(); } [Fact] public async Task Create() { const string newTitle = "new category"; var cat1 = new Category { CategoryID = 123, SortOrder = 0 }; var cat2 = new Category { CategoryID = 456, SortOrder = 2 }; var cat3 = new Category { CategoryID = 789, SortOrder = 4 }; var cat4 = new Category { CategoryID = 1000, SortOrder = 6 }; var newCat = new Category { CategoryID = 999, Title = newTitle, SortOrder = -2}; var cats = new List { cat1, cat2, cat3, cat4, newCat }; var service = GetService(); _mockCategoryRepo.GetAll().Returns(Task.FromResult(cats)); _mockCategoryRepo.Create(newTitle, -2).Returns(Task.FromResult(newCat)); var result = await service.Create(newTitle); Assert.Equal(0, result.SortOrder); Assert.Equal(999, result.CategoryID); Assert.Equal(newTitle, result.Title); await _mockCategoryRepo.Received().Create(newTitle, -2); Assert.Equal(0, newCat.SortOrder); Assert.Equal(2, cat1.SortOrder); Assert.Equal(4, cat2.SortOrder); Assert.Equal(6, cat3.SortOrder); Assert.Equal(8, cat4.SortOrder); } [Fact] public void Delete() { var service = GetService(); var cat = new Category { CategoryID = 123 }; service.Delete(cat); _mockCategoryRepo.Received().Delete(cat.CategoryID); } [Fact] public async Task DeleteByIdThrowsIfNotFound() { var service = GetService(); _mockCategoryRepo.Get(Arg.Any()).Returns(Task.FromResult((Category)null)); await Assert.ThrowsAsync(async () => await service.Delete(1)); } [Fact] public void DeleteResetsForumCatIDsToNull() { var service = GetService(); var cat = new Category { CategoryID = 123 }; var f1 = new Forum { ForumID = 1, CategoryID = cat.CategoryID }; var f2 = new Forum { ForumID = 2, CategoryID = cat.CategoryID }; var f3 = new Forum { ForumID = 3, CategoryID = 456 }; var forums = new List {f1, f2, f3}; _mockForumRepo.GetAll().Returns(forums); service.Delete(cat); _mockForumRepo.Received().UpdateCategoryAssociation(1, null); _mockForumRepo.Received().UpdateCategoryAssociation(2, null); _mockForumRepo.DidNotReceive().UpdateCategoryAssociation(3, null); } [Fact] public void UpdateTitle() { var savedCategory = new Category { CategoryID = 789 }; var service = GetService(); var cat = new Category { CategoryID = 123, Title = "old", SortOrder = 456 }; _mockCategoryRepo.Update(Arg.Do(x => savedCategory = x)); service.UpdateTitle(cat, "new"); _mockCategoryRepo.Received().Update(Arg.Any()); Assert.Equal("new", savedCategory.Title); Assert.Equal(123, savedCategory.CategoryID); Assert.Equal(456, savedCategory.SortOrder); } [Fact] public async Task UpdateTitleByIdThrowsIfNotFound() { var service = GetService(); _mockCategoryRepo.Get(Arg.Any()).Returns((Category)null); await Assert.ThrowsAsync(async () => await service.UpdateTitle(1, "")); } [Fact] public async Task MoveUp() { var cat1 = new Category { CategoryID = 123, SortOrder = 0}; var cat2 = new Category { CategoryID = 456, SortOrder = 2}; var cat3 = new Category { CategoryID = 789, SortOrder = 4}; var cat4 = new Category { CategoryID = 1000, SortOrder = 6}; var cats = new List { cat1, cat2, cat3, cat4 }; var service = GetService(); _mockCategoryRepo.GetAll().Returns(Task.FromResult(cats)); await service.MoveCategoryUp(cat3); await _mockCategoryRepo.Received().GetAll(); await _mockCategoryRepo.Received(4).Update(Arg.Any()); await _mockCategoryRepo.Received().Update(cat1); await _mockCategoryRepo.Received().Update(cat2); await _mockCategoryRepo.Received().Update(cat3); await _mockCategoryRepo.Received().Update(cat4); Assert.Equal(0, cat1.SortOrder); Assert.Equal(2, cat3.SortOrder); Assert.Equal(4, cat2.SortOrder); Assert.Equal(6, cat4.SortOrder); } [Fact] public async Task MoveDown() { var cat1 = new Category { CategoryID = 123, SortOrder = 0 }; var cat2 = new Category { CategoryID = 456, SortOrder = 2 }; var cat3 = new Category { CategoryID = 789, SortOrder = 4 }; var cat4 = new Category { CategoryID = 1000, SortOrder = 6 }; var cats = new List { cat1, cat2, cat3, cat4 }; var service = GetService(); _mockCategoryRepo.GetAll().Returns(Task.FromResult(cats)); await service.MoveCategoryDown(cat3); await _mockCategoryRepo.Received().GetAll(); await _mockCategoryRepo.Received(4).Update(Arg.Any()); await _mockCategoryRepo.Received().Update(cat1); await _mockCategoryRepo.Received().Update(cat2); await _mockCategoryRepo.Received().Update(cat3); await _mockCategoryRepo.Received().Update(cat4); Assert.Equal(0, cat1.SortOrder); Assert.Equal(2, cat2.SortOrder); Assert.Equal(4, cat4.SortOrder); Assert.Equal(6, cat3.SortOrder); } [Fact] public async Task MoveUpByIdThrowsIfNotFound() { var service = GetService(); _mockCategoryRepo.Get(Arg.Any()).Returns((Category)null); await Assert.ThrowsAsync(async () => await service.MoveCategoryUp(1)); } [Fact] public async Task MoveDownByIdThrowsIfNotFound() { var service = GetService(); _mockCategoryRepo.Get(Arg.Any()).Returns((Category)null); await Assert.ThrowsAsync(async () => await service.MoveCategoryDown(1)); } } ================================================ FILE: src/PopForums.Test/Services/ClaimsToRoleMapperTests.cs ================================================ using System.Security.Claims; namespace PopForums.Test.Services; public class ClaimsToRoleMapperTests { private IConfig _config; private IRoleRepository _roleRepo; private ClaimsToRoleMapper GetService() { _config = Substitute.For(); _roleRepo = Substitute.For(); return new ClaimsToRoleMapper(_config, _roleRepo); } public class MapRoles : ClaimsToRoleMapperTests { [Fact] public async Task NoMappingWithNoClaims() { var service = GetService(); _config.OAuthAdminClaimType.Returns((string)null); _config.OAuthAdminClaimValue.Returns((string)null); var user = new User { Roles = new List(), UserID = 123 }; var claims = new List(); var savedRoles = Array.Empty(); await _roleRepo.ReplaceUserRoles(user.UserID, Arg.Do(x => savedRoles = x)); await service.MapRoles(user, claims); Assert.Empty(savedRoles); } [Fact] public async Task NoMappingWithNoMatchingClaims() { var service = GetService(); _config.OAuthAdminClaimType.Returns("iowfhwe"); _config.OAuthAdminClaimValue.Returns("efoijh"); _config.OAuthModeratorClaimType.Returns("iowfhwe"); _config.OAuthModeratorClaimValue.Returns("efoijh"); var user = new User { Roles = new List(), UserID = 123 }; var claims = new List(); var savedRoles = Array.Empty(); await _roleRepo.ReplaceUserRoles(user.UserID, Arg.Do(x => savedRoles = x)); await service.MapRoles(user, claims); Assert.Empty(savedRoles); } [Fact] public async Task NoMappingWithNoMatchingClaimsValues() { var service = GetService(); _config.OAuthAdminClaimType.Returns("admin"); _config.OAuthAdminClaimValue.Returns("yes"); _config.OAuthModeratorClaimType.Returns("mod"); _config.OAuthModeratorClaimValue.Returns("yes"); var user = new User { Roles = new List(), UserID = 123 }; var claims = new List { new ("admin", "no"), new ("mod", "no") }; var savedRoles = Array.Empty(); await _roleRepo.ReplaceUserRoles(user.UserID, Arg.Do(x => savedRoles = x)); await service.MapRoles(user, claims); Assert.Empty(savedRoles); } [Fact] public async Task AdminNameNoValueMapsAdminRole() { var service = GetService(); _config.OAuthAdminClaimType.Returns("adminclaim"); _config.OAuthAdminClaimValue.Returns((string)null); var user = new User { Roles = new List(), UserID = 123 }; var claims = new List { new ("adminclaim", string.Empty) }; var savedRoles = Array.Empty(); await _roleRepo.ReplaceUserRoles(user.UserID, Arg.Do(x => savedRoles = x)); await service.MapRoles(user, claims); Assert.Contains(PermanentRoles.Admin, savedRoles); } [Fact] public async Task AdminNameWithValueMapsAdminRole() { var service = GetService(); _config.OAuthAdminClaimType.Returns("adminclaim"); _config.OAuthAdminClaimValue.Returns("adminvalue"); var user = new User { Roles = new List(), UserID = 123 }; var claims = new List { new ("adminclaim", "adminvalue") }; var savedRoles = Array.Empty(); await _roleRepo.ReplaceUserRoles(user.UserID, Arg.Do(x => savedRoles = x)); await service.MapRoles(user, claims); Assert.Contains(PermanentRoles.Admin, savedRoles); } [Fact] public async Task ModNameNoValueMapsModRole() { var service = GetService(); _config.OAuthModeratorClaimType.Returns("modclaim"); _config.OAuthModeratorClaimValue.Returns((string)null); var user = new User { Roles = new List(), UserID = 123 }; var claims = new List { new ("modclaim", string.Empty) }; var savedRoles = Array.Empty(); await _roleRepo.ReplaceUserRoles(user.UserID, Arg.Do(x => savedRoles = x)); await service.MapRoles(user, claims); Assert.Contains(PermanentRoles.Moderator, savedRoles); } [Fact] public async Task ModNameWithValueMapsModRole() { var service = GetService(); _config.OAuthAdminClaimType.Returns("modclaim"); _config.OAuthAdminClaimValue.Returns("modvalue"); var user = new User { Roles = new List(), UserID = 123 }; var claims = new List { new ("modclaim", "modvalue") }; var savedRoles = Array.Empty(); await _roleRepo.ReplaceUserRoles(user.UserID, Arg.Do(x => savedRoles = x)); await service.MapRoles(user, claims); Assert.Contains(PermanentRoles.Admin, savedRoles); } } } ================================================ FILE: src/PopForums.Test/Services/CloseAgedTopicsWorkerTests.cs ================================================ using NSubstitute.ExceptionExtensions; namespace PopForums.Test.Services; public class CloseAgedTopicsWorkerTests { private ITopicService _topicService; private IErrorLog _errorLog; private CloseAgedTopicsWorker GetWorker() { _topicService = Substitute.For(); _errorLog = Substitute.For(); return new CloseAgedTopicsWorker(_topicService, _errorLog); } [Fact] public void NoErrorNoLog() { var worker = GetWorker(); _topicService.CloseAgedTopics().Returns(Task.CompletedTask); worker.Execute(); _errorLog.DidNotReceive().Log(Arg.Any(), Arg.Any()); } [Fact] public void LogWhenThrows() { var worker = GetWorker(); _topicService.CloseAgedTopics().ThrowsAsync(); worker.Execute(); _errorLog.Received().Log(Arg.Any(), Arg.Any()); } } ================================================ FILE: src/PopForums.Test/Services/FavoriteTopicServiceTests.cs ================================================ namespace PopForums.Test.Services; public class FavoriteTopicServiceTests { private IFavoriteTopicsRepository _mockFaveRepo; private ISettingsManager _mockSettingsManager; private FavoriteTopicService GetService() { _mockFaveRepo = Substitute.For(); _mockSettingsManager = Substitute.For(); return new FavoriteTopicService(_mockSettingsManager, _mockFaveRepo); } [Fact] public async Task GetTopicsFromRepo() { var user = new User { UserID = 123 }; var service = GetService(); var settings = new Settings { TopicsPerPage = 20 }; _mockSettingsManager.Current.Returns(settings); var list = new List(); _mockFaveRepo.GetFavoriteTopics(user.UserID, 1, 20).Returns(Task.FromResult(list)); var result = await service.GetTopics(user, 1); Assert.Same(list, result.Item1); } [Fact] public async Task AddFaveTopic() { var service = GetService(); var user = new User { UserID = 123 }; var topic = new Topic { TopicID = 456 }; await service.AddFavoriteTopic(user, topic); await _mockFaveRepo.Received().AddFavoriteTopic(user.UserID, topic.TopicID); } [Fact] public async Task RemoveFaveTopic() { var service = GetService(); var user = new User { UserID = 123 }; var topic = new Topic { TopicID = 456 }; await service.RemoveFavoriteTopic(user, topic); await _mockFaveRepo.Received().RemoveFavoriteTopic(user.UserID, topic.TopicID); } [Fact] public async Task GetTopicsStartRowCalcd() { var user = new User { UserID = 123 }; var service = GetService(); var settings = new Settings { TopicsPerPage = 20 }; _mockSettingsManager.Current.Returns(settings); var result = await service.GetTopics(user, 3); await _mockFaveRepo.Received().GetFavoriteTopics(user.UserID, 41, 20); result.Item2.PageSize = settings.TopicsPerPage; } } ================================================ FILE: src/PopForums.Test/Services/ForumPermissionServiceTests.cs ================================================ namespace PopForums.Test.Services; public class ForumPermissionServiceTests { private ForumPermissionService GetService() { _mockForumRepo = Substitute.For(); return new ForumPermissionService(_mockForumRepo); } private IForumRepository _mockForumRepo; private User GetUser() { var user = Models.UserTest.GetTestUser(); user.Roles = new List(); return user; } [Fact] public async Task NoViewRestrictionWithUser() { var forumService = GetService(); _mockForumRepo.GetForumPostRoles(1).Returns(Task.FromResult(new List())); _mockForumRepo.GetForumViewRoles(1).Returns(Task.FromResult(new List())); var permission = await forumService.GetPermissionContext(new Forum { ForumID = 1 }, GetUser()); Assert.True(permission.UserCanView); Assert.Empty(permission.DenialReason); } [Fact] public async Task NoViewRestrictionWithoutUser() { var forumService = GetService(); _mockForumRepo.GetForumPostRoles(1).Returns(Task.FromResult(new List())); _mockForumRepo.GetForumViewRoles(1).Returns(Task.FromResult(new List())); var permission = await forumService.GetPermissionContext(new Forum { ForumID = 1 }, null); Assert.True(permission.UserCanView); } [Fact] public async Task ViewRestrictionUserNotInRole() { var forumService = GetService(); _mockForumRepo.GetForumPostRoles(1).Returns(Task.FromResult(new List())); _mockForumRepo.GetForumViewRoles(1).Returns(Task.FromResult(new List { "blah" })); var permission = await forumService.GetPermissionContext(new Forum { ForumID = 1 }, GetUser()); Assert.False(permission.UserCanView); } [Fact] public async Task ViewRestrictionUserCantPostEither() { var forumService = GetService(); _mockForumRepo.GetForumPostRoles(1).Returns(Task.FromResult(new List())); _mockForumRepo.GetForumViewRoles(1).Returns(Task.FromResult(new List { "blah" })); var permission = await forumService.GetPermissionContext(new Forum { ForumID = 1 }, GetUser()); Assert.False(permission.UserCanView); Assert.False(permission.UserCanPost); } [Fact] public async Task ViewRestrictionNoUser() { var forumService = GetService(); _mockForumRepo.GetForumPostRoles(1).Returns(Task.FromResult(new List())); _mockForumRepo.GetForumViewRoles(1).Returns(Task.FromResult(new List { "blah" })); var permission = await forumService.GetPermissionContext(new Forum { ForumID = 1 }, null); Assert.False(permission.UserCanView); } [Fact] public async Task ViewRestrictionUserInRole() { var user = GetUser(); user.Roles.Add("blah"); var forumService = GetService(); _mockForumRepo.GetForumPostRoles(1).Returns(Task.FromResult(new List())); _mockForumRepo.GetForumViewRoles(1).Returns(Task.FromResult(new List { "blah" })); var permission = await forumService.GetPermissionContext(new Forum { ForumID = 1 }, user); Assert.True(permission.UserCanView); } [Fact] public async Task PostRestrictionNoUser() { var forumService = GetService(); _mockForumRepo.GetForumPostRoles(1).Returns(Task.FromResult(new List { "blah" })); _mockForumRepo.GetForumViewRoles(1).Returns(Task.FromResult(new List())); var permission = await forumService.GetPermissionContext(new Forum { ForumID = 1 }, null); Assert.False(permission.UserCanPost); } [Fact] public async Task PostRestrictionUserInRole() { var user = GetUser(); user.Roles.Add("blah"); var forumService = GetService(); _mockForumRepo.GetForumPostRoles(1).Returns(Task.FromResult(new List { "blah" })); _mockForumRepo.GetForumViewRoles(1).Returns(Task.FromResult(new List())); var permission = await forumService.GetPermissionContext(new Forum { ForumID = 1 }, user); Assert.True(permission.UserCanPost); Assert.Empty(permission.DenialReason); } [Fact] public async Task PostRestrictionUserNotApproved() { var user = GetUser(); user.IsApproved = false; var forumService = GetService(); _mockForumRepo.GetForumPostRoles(1).Returns(Task.FromResult(new List())); _mockForumRepo.GetForumViewRoles(1).Returns(Task.FromResult(new List())); var permission = await forumService.GetPermissionContext(new Forum { ForumID = 1 }, user); Assert.False(permission.UserCanPost); Assert.NotEmpty(permission.DenialReason); } [Fact] public async Task PostRestrictionUserNotInRole() { var forumService = GetService(); _mockForumRepo.GetForumPostRoles(1).Returns(Task.FromResult(new List { "blah" })); _mockForumRepo.GetForumViewRoles(1).Returns(Task.FromResult(new List())); var permission = await forumService.GetPermissionContext(new Forum { ForumID = 1 }, GetUser()); Assert.False(permission.UserCanPost); Assert.NotEmpty(permission.DenialReason); } [Fact] public async Task ModerateNoUser() { var forumService = GetService(); _mockForumRepo.GetForumPostRoles(1).Returns(Task.FromResult(new List())); _mockForumRepo.GetForumViewRoles(1).Returns(Task.FromResult(new List())); var permission = await forumService.GetPermissionContext(new Forum { ForumID = 1 }, null); Assert.False(permission.UserCanModerate); } [Fact] public async Task ModerateUserIsAdmin() { var user = GetUser(); user.Roles.Add(PermanentRoles.Admin); var forumService = GetService(); _mockForumRepo.GetForumPostRoles(1).Returns(Task.FromResult(new List())); _mockForumRepo.GetForumViewRoles(1).Returns(Task.FromResult(new List())); var permission = await forumService.GetPermissionContext(new Forum { ForumID = 1 }, user); Assert.True(permission.UserCanModerate); } [Fact] public async Task ModerateUserIsModerator() { var user = GetUser(); user.Roles.Add(PermanentRoles.Moderator); var forumService = GetService(); _mockForumRepo.GetForumPostRoles(1).Returns(Task.FromResult(new List())); _mockForumRepo.GetForumViewRoles(1).Returns(Task.FromResult(new List())); var permission = await forumService.GetPermissionContext(new Forum { ForumID = 1 }, user); Assert.True(permission.UserCanModerate); } [Fact] public async Task TopicClosed() { var user = GetUser(); var forumService = GetService(); _mockForumRepo.GetForumPostRoles(1).Returns(Task.FromResult(new List())); _mockForumRepo.GetForumViewRoles(1).Returns(Task.FromResult(new List())); var premission = await forumService.GetPermissionContext(new Forum { ForumID = 1 }, user, new Topic { TopicID = 4, IsClosed = true }); Assert.False(premission.UserCanPost); } [Fact] public async Task TopicOpen() { var user = GetUser(); var forumService = GetService(); _mockForumRepo.GetForumPostRoles(1).Returns(Task.FromResult(new List())); _mockForumRepo.GetForumViewRoles(1).Returns(Task.FromResult(new List())); var premission = await forumService.GetPermissionContext(new Forum { ForumID = 1 }, user, new Topic { TopicID = 4, IsClosed = false }); Assert.True(premission.UserCanPost); } [Fact] public async Task WithUserTopicDeleted() { var user = GetUser(); var forumService = GetService(); _mockForumRepo.GetForumPostRoles(1).Returns(Task.FromResult(new List())); _mockForumRepo.GetForumViewRoles(1).Returns(Task.FromResult(new List())); var premission = await forumService.GetPermissionContext(new Forum { ForumID = 1 }, user, new Topic { TopicID = 4, IsDeleted = true }); Assert.False(premission.UserCanView); } [Fact] public async Task AnonTopicDeleted() { var forumService = GetService(); _mockForumRepo.GetForumPostRoles(1).Returns(Task.FromResult(new List())); _mockForumRepo.GetForumViewRoles(1).Returns(Task.FromResult(new List())); var premission = await forumService.GetPermissionContext(new Forum { ForumID = 1 }, null, new Topic { TopicID = 4, IsDeleted = true }); Assert.False(premission.UserCanView); } [Fact] public async Task ModOnTopicDeleted() { var user = GetUser(); user.Roles.Add(PermanentRoles.Moderator); var forumService = GetService(); _mockForumRepo.GetForumPostRoles(1).Returns(Task.FromResult(new List())); _mockForumRepo.GetForumViewRoles(1).Returns(Task.FromResult(new List())); var premission = await forumService.GetPermissionContext(new Forum { ForumID = 1 }, user, new Topic { TopicID = 4, IsDeleted = true }); Assert.True(premission.UserCanView); } [Fact] public async Task ForumNotArchived() { var user = GetUser(); var forumService = GetService(); _mockForumRepo.GetForumPostRoles(1).Returns(Task.FromResult(new List())); _mockForumRepo.GetForumViewRoles(1).Returns(Task.FromResult(new List())); var premission = await forumService.GetPermissionContext(new Forum { ForumID = 1, IsArchived = false }, user, new Topic { TopicID = 4 }); Assert.True(premission.UserCanPost); } [Fact] public async Task ForumIsArchived() { var user = GetUser(); var forumService = GetService(); _mockForumRepo.GetForumPostRoles(1).Returns(Task.FromResult(new List())); _mockForumRepo.GetForumViewRoles(1).Returns(Task.FromResult(new List())); var premission = await forumService.GetPermissionContext(new Forum { ForumID = 1, IsArchived = true }, user, new Topic { TopicID = 4 }); Assert.False(premission.UserCanPost); } } ================================================ FILE: src/PopForums.Test/Services/ForumServiceTests.cs ================================================ namespace PopForums.Test.Services; public class ForumServiceTests { private IForumRepository _mockForumRepo; private ITopicRepository _mockTopicRepo; private ICategoryRepository _mockCategoryRepo; private ISettingsManager _mockSettingsManager; private ILastReadService _mockLastReadService; private ForumService GetService() { _mockCategoryRepo = Substitute.For(); _mockForumRepo = Substitute.For(); _mockTopicRepo = Substitute.For(); _mockSettingsManager = Substitute.For(); _mockLastReadService = Substitute.For(); return new ForumService(_mockForumRepo, _mockTopicRepo, _mockCategoryRepo, _mockSettingsManager, _mockLastReadService); } [Fact] public async Task Get() { const int forumID = 123; var forumService = GetService(); _mockForumRepo.Get(forumID).Returns(Task.FromResult(new Forum {ForumID = forumID})); var forum = await forumService.Get(forumID); Assert.Equal(forumID, forum.ForumID); await _mockForumRepo.Received().Get(forumID); } [Fact] public async Task Create() { var forumService = GetService(); const int categoryID = 456; const string title = "forum title"; const string desc = "description of forum"; const bool isVisible = true; const bool isArchived = true; const int sortOrder = 5; const int forumID = 123; const string adapter = "Jeff.Adapter"; const bool isQAForum = true; var forum = new Forum {ForumID = forumID, CategoryID = categoryID, Title = title, Description = desc, IsVisible = isVisible, IsArchived = isArchived, SortOrder = sortOrder}; _mockForumRepo.Create(categoryID, title, desc, isVisible, isArchived, sortOrder, Arg.Any(), adapter, isQAForum).Returns(Task.FromResult(forum)); _mockForumRepo.GetUrlNamesThatStartWith(Arg.Any()).Returns(Task.FromResult(new List())); _mockForumRepo.GetAll().Returns(new List { new Forum { ForumID = 1, SortOrder = 9 }, new Forum { ForumID = 2, SortOrder = 6 }, forum}); var result = await forumService.Create(categoryID, title, desc, isVisible, isArchived, sortOrder, adapter, isQAForum); Assert.Equal(forum, result); await _mockForumRepo.Received().Create(categoryID, title, desc, isVisible, isArchived, sortOrder, Arg.Any(), adapter, isQAForum); await _mockForumRepo.Received().UpdateSortOrder(123, 0); await _mockForumRepo.Received().UpdateSortOrder(2, 2); await _mockForumRepo.Received().UpdateSortOrder(1, 4); } [Fact] public async Task CreateMakesUrlTitle() { var forumService = GetService(); const int categoryID = 456; const string title = "forum title"; const string desc = "description of forum"; const bool isVisible = true; const bool isArchived = true; const int sortOrder = 5; const int forumID = 123; const string adapter = "Jeff.Adapter"; const bool isQAForum = true; var forum = new Forum { ForumID = forumID, CategoryID = categoryID, Title = title, Description = desc, IsVisible = isVisible, IsArchived = isArchived, SortOrder = sortOrder }; _mockForumRepo.Create(categoryID, title, desc, isVisible, isArchived, sortOrder, Arg.Any(), adapter, isQAForum).Returns(Task.FromResult(forum)); _mockForumRepo.GetUrlNamesThatStartWith(Arg.Any()).Returns(Task.FromResult(new List())); await forumService.Create(categoryID, title, desc, isVisible, isArchived, sortOrder, adapter, isQAForum); await _mockForumRepo.Received().Create(categoryID, title, desc, isVisible, isArchived, sortOrder, "forum-title", adapter, isQAForum); } [Fact] public async Task CreateMakesUrlTitleWithAppendage() { var forumService = GetService(); const int categoryID = 456; const string title = "forum title"; const string desc = "description of forum"; const bool isVisible = true; const bool isArchived = true; const int sortOrder = 5; const int forumID = 123; const string adapter = "Jeff.Adapter"; const bool isQAForum = true; var forum = new Forum { ForumID = forumID, CategoryID = categoryID, Title = title, Description = desc, IsVisible = isVisible, IsArchived = isArchived, SortOrder = sortOrder }; _mockForumRepo.Create(categoryID, title, desc, isVisible, isArchived, sortOrder, Arg.Any(), adapter, isQAForum).Returns(Task.FromResult(forum)); _mockForumRepo.GetUrlNamesThatStartWith(title.ToUrlName()).Returns(Task.FromResult(new List {"forum-title", "forum-title-but-not", "forum-title-2"})); await forumService.Create(categoryID, title, desc, isVisible, isArchived, sortOrder, adapter, isQAForum); await _mockForumRepo.Received().Create(categoryID, title, desc, isVisible, isArchived, sortOrder, "forum-title-3", adapter, isQAForum); } [Fact] public async Task UpdateLast() { const int forumID = 123; const int topicID = 456; var lastTime = new DateTime(2001, 2, 2); const string lastName = "Jeff"; var forum = new Forum { ForumID = forumID }; var topic = new Topic { TopicID = topicID, LastPostTime = lastTime, LastPostName = lastName }; var forumService = GetService(); _mockTopicRepo.GetLastUpdatedTopic(forum.ForumID).Returns(Task.FromResult(topic)); await forumService.UpdateLast(forum); await _mockTopicRepo.Received().GetLastUpdatedTopic(forum.ForumID); await _mockForumRepo.Received().UpdateLastTimeAndUser(forum.ForumID, lastTime, lastName); } [Fact] public async Task UpdateLastWithValues() { var forumService = GetService(); const int forumID = 123; var lastTime = new DateTime(2001, 2, 2); const string lastName = "Jeff"; var forum = new Forum { ForumID = forumID }; await forumService.UpdateLast(forum, lastTime, lastName); await _mockForumRepo.Received().UpdateLastTimeAndUser(forum.ForumID, lastTime, lastName); } //[Fact] //[Ignore] // TODO: gotta account for spawned thread //public void UpdateCounts() //{ // const int topicCount = 456; // const int postCount = 789; // const int forumID = 123; // var forum = new Forum(forumID); // var forumService = GetService(); // _mockTopicRepo.GetPostCount(forumID, false).Returns(postCount); // _mockTopicRepo.GetTopicCount(forumID, false).Returns(topicCount); // forumService.UpdateCounts(forum); // _mockTopicRepo.Received().GetPostCount(forumID, false); // _mockTopicRepo.Received().GetTopicCount(forumID, false); // _mockForumRepo.Verify(f => f.UpdateTopicAndPostCounts(forumID, topicCount, postCount)); //} [Fact] public async Task GetForumsWithCategories() { var forums = new List(); var cats = new List(); var forumService = GetService(); _mockForumRepo.GetAll().Returns(forums); _mockCategoryRepo.GetAll().Returns(Task.FromResult(cats)); _mockSettingsManager.Current.ForumTitle.Returns("whatever"); var container = await forumService.GetCategorizedForumContainer(); await _mockCategoryRepo.Received().GetAll(); await _mockForumRepo.Received().GetAll(); Assert.Equal(container.AllForums, forums); Assert.Equal(container.AllCategories, cats); } [Fact] public async Task MoveUp() { var f1 = new Forum { ForumID = 123, SortOrder = 0, CategoryID = 777 }; var f2 = new Forum { ForumID = 456, SortOrder = 2, CategoryID = 777 }; var f3 = new Forum { ForumID = 789, SortOrder = 4, CategoryID = 777 }; var f4 = new Forum { ForumID = 1000,SortOrder = 6, CategoryID = 777 }; var forums = new List { f1, f2, f3, f4 }; var service = GetService(); _mockForumRepo.GetForumsInCategory(777).Returns(Task.FromResult(forums)); _mockForumRepo.Get(f3.ForumID).Returns(Task.FromResult(f3)); await service.MoveForumUp(f3.ForumID); await _mockForumRepo.Received().GetForumsInCategory(777); await _mockForumRepo.Received(4).UpdateSortOrder(Arg.Any(), Arg.Any()); await _mockForumRepo.Received().UpdateSortOrder(f1.ForumID, f1.SortOrder); await _mockForumRepo.Received().UpdateSortOrder(f2.ForumID, f2.SortOrder); await _mockForumRepo.Received().UpdateSortOrder(f3.ForumID, f3.SortOrder); await _mockForumRepo.Received().UpdateSortOrder(f4.ForumID, f4.SortOrder); Assert.Equal(0, f1.SortOrder); Assert.Equal(2, f3.SortOrder); Assert.Equal(4, f2.SortOrder); Assert.Equal(6, f4.SortOrder); } [Fact] public async Task MoveDown() { var f1 = new Forum { ForumID = 123, SortOrder = 0, CategoryID = 777 }; var f2 = new Forum { ForumID = 456, SortOrder = 2, CategoryID = 777 }; var f3 = new Forum { ForumID = 789, SortOrder = 4, CategoryID = 777 }; var f4 = new Forum { ForumID = 1000, SortOrder = 6, CategoryID = 777 }; var forums = new List { f1, f2, f3, f4 }; var service = GetService(); _mockForumRepo.GetForumsInCategory(777).Returns(Task.FromResult(forums)); _mockForumRepo.Get(f3.ForumID).Returns(Task.FromResult(f3)); await service.MoveForumDown(f3.ForumID); await _mockForumRepo.Received().GetForumsInCategory(777); await _mockForumRepo.Received(4).UpdateSortOrder(Arg.Any(), Arg.Any()); await _mockForumRepo.Received().UpdateSortOrder(f1.ForumID, f1.SortOrder); await _mockForumRepo.Received().UpdateSortOrder(f2.ForumID, f2.SortOrder); await _mockForumRepo.Received().UpdateSortOrder(f3.ForumID, f3.SortOrder); await _mockForumRepo.Received().UpdateSortOrder(f4.ForumID, f4.SortOrder); Assert.Equal(0, f1.SortOrder); Assert.Equal(2, f2.SortOrder); Assert.Equal(4, f4.SortOrder); Assert.Equal(6, f3.SortOrder); } [Fact] public async Task MoveForumUpThrowsIfNoForum() { var service = GetService(); _mockForumRepo.Get(Arg.Any()).Returns((Forum) null); await Assert.ThrowsAsync(async () => await service.MoveForumUp(1)); } [Fact] public async Task MoveForumDownThrowsIfNoForum() { var service = GetService(); _mockForumRepo.Get(Arg.Any()).Returns((Forum)null); await Assert.ThrowsAsync(async () => await service.MoveForumDown(1)); } [Fact] public async Task PostRestrictions() { var service = GetService(); var forum = new Forum { ForumID = 1 }; var roles = new List {"leader", "follower"}; _mockForumRepo.GetForumPostRoles(forum.ForumID).Returns(Task.FromResult(roles)); var result = await service.GetForumPostRoles(forum); await _mockForumRepo.Received().GetForumPostRoles(forum.ForumID); Assert.Same(roles, result); } [Fact] public async Task ViewRestrictions() { var service = GetService(); var forum = new Forum { ForumID = 1 }; var roles = new List { "leader", "follower" }; _mockForumRepo.GetForumViewRoles(forum.ForumID).Returns(Task.FromResult(roles)); var result = await service.GetForumViewRoles(forum); await _mockForumRepo.Received().GetForumViewRoles(forum.ForumID); Assert.Same(roles, result); } [Fact] public async Task GetViewableForumIDsFromViewRestrictedForumsReturnsEmptyDictionaryWithoutUser() { var graph = new Dictionary> { {1, new List {"blah"}}, {2, new List()}, {3, new List {"blah"}} }; var service = GetService(); _mockForumRepo.GetAllVisible().Returns(new List { new Forum { ForumID = 1 }, new Forum { ForumID = 2 }, new Forum { ForumID = 3 } }); _mockForumRepo.GetForumViewRestrictionRoleGraph().Returns(Task.FromResult(graph)); var result = await service.GetViewableForumIDsFromViewRestrictedForums(null); Assert.Single(result); Assert.Equal(2, result[0]); } [Fact] public async Task GetViewableForumIDsFromViewRestrictedForumsDoesntIncludeForumsWithNoViewRestrictions() { var graph = new Dictionary>(); graph.Add(1, new List { "blah" }); graph.Add(2, new List()); graph.Add(3, new List { "blah" }); var service = GetService(); _mockForumRepo.GetForumViewRestrictionRoleGraph().Returns(Task.FromResult(graph)); _mockForumRepo.GetAllVisible().Returns(new List { new Forum { ForumID = 1 }, new Forum { ForumID = 2 }, new Forum { ForumID = 3 } }); var result = await service.GetViewableForumIDsFromViewRestrictedForums(new User { UserID = 123, Roles = new [] {"blah"}.ToList() }); Assert.Equal(3, result.Count); } [Fact] public async Task GetViewableForumIDsFromViewRestrictedForumsReturnsIDsWithMatchingUserRoles() { var graph = new Dictionary>(); graph.Add(1, new List { "blah" }); graph.Add(2, new List()); graph.Add(3, new List { "blep" }); graph.Add(4, new List { "burp", "blah" }); graph.Add(5, new List { "burp" }); var service = GetService(); _mockForumRepo.GetForumViewRestrictionRoleGraph().Returns(Task.FromResult(graph)); _mockForumRepo.GetAllVisible().Returns(new List { new Forum { ForumID = 1 }, new Forum { ForumID = 2 }, new Forum { ForumID = 3 }, new Forum { ForumID = 4 }, new Forum { ForumID = 5 } }); var result = await service.GetViewableForumIDsFromViewRestrictedForums(new User { UserID = 123, Roles = new[] { "blah", "blep" }.ToList() }); Assert.Equal(4, result.Count); Assert.Contains(1, result); Assert.Contains(2, result); Assert.Contains(3, result); Assert.Contains(4, result); Assert.DoesNotContain(5, result); } [Fact] public async Task GetNonViewableDoesntIncludeForumsWithNoViewRestrictions() { var graph = new Dictionary>(); graph.Add(1, new List { "blah" }); graph.Add(2, new List()); graph.Add(3, new List { "blah" }); var service = GetService(); _mockForumRepo.GetForumViewRestrictionRoleGraph().Returns(Task.FromResult(graph)); var result = await service.GetNonViewableForumIDs(new User { UserID = 123, Roles = new List()}); Assert.Equal(2, result.Count); Assert.DoesNotContain(2, result); } [Fact] public async Task GetNonViewableDoesntIncludeForumsWithRoleMatchingViewRestrictions() { var graph = new Dictionary>(); graph.Add(1, new List { "blah" }); graph.Add(2, new List()); graph.Add(3, new List { "OK" }); var service = GetService(); _mockForumRepo.GetForumViewRestrictionRoleGraph().Returns(Task.FromResult(graph)); var result = await service.GetNonViewableForumIDs(new User { UserID = 123, Roles = new List { "OK" } }); Assert.Single(result); Assert.DoesNotContain(3, result); } [Fact] public async Task GetNonViewableIncludesForumsWithNoMatchingViewRestrictions() { var graph = new Dictionary>(); graph.Add(1, new List { "blah" }); graph.Add(2, new List()); graph.Add(3, new List { "OK" }); var service = GetService(); _mockForumRepo.GetForumViewRestrictionRoleGraph().Returns(Task.FromResult(graph)); var result = await service.GetNonViewableForumIDs(new User { UserID = 123, Roles = new List { "OK" } }); Assert.Single(result); Assert.Equal(1, result[0]); } [Fact] public async Task GetNonViewableExcludesViewRestrictionsForNoUser() { var graph = new Dictionary>(); graph.Add(1, new List { "blah" }); graph.Add(2, new List()); graph.Add(3, new List { "OK" }); var service = GetService(); _mockForumRepo.GetForumViewRestrictionRoleGraph().Returns(Task.FromResult(graph)); var result = await service.GetNonViewableForumIDs(null); Assert.Equal(2, result.Count); Assert.Equal(1, result[0]); Assert.Equal(3, result[1]); } [Fact] public async Task GetCategorizedForUserHasOnlyViewableForums() { var graph = new Dictionary>(); graph.Add(1, new List { "blah" }); graph.Add(2, new List()); graph.Add(3, new List { "OK" }); var allForums = new List {new Forum { ForumID = 1 }, new Forum { ForumID = 2 }, new Forum { ForumID = 3 } }; var service = GetService(); _mockForumRepo.GetForumViewRestrictionRoleGraph().Returns(Task.FromResult(graph)); _mockForumRepo.GetAllVisible().Returns(allForums); _mockCategoryRepo.GetAll().Returns(Task.FromResult(new List())); _mockSettingsManager.Current.ForumTitle.Returns("whatever"); var container = await service.GetCategorizedForumContainerFilteredForUser(new User { UserID = 123, Roles = new List { "OK" } }); Assert.Equal(2, container.UncategorizedForums.Count); Assert.Null(container.UncategorizedForums.SingleOrDefault(f => f.ForumID == 1)); } [Fact] public async Task GetCategorizedForUserPopulatesReadStatus() { var service = GetService(); var user = new User { UserID = 123 }; _mockCategoryRepo.GetAll().Returns(Task.FromResult(new List())); _mockForumRepo.GetAllVisible().Returns(new List()); _mockForumRepo.GetForumViewRestrictionRoleGraph().Returns(Task.FromResult(new Dictionary>())); _mockSettingsManager.Current.ForumTitle.Returns(""); await service.GetCategorizedForumContainerFilteredForUser(user); await _mockLastReadService.Received(1).GetForumReadStatus(user, Arg.Any()); } [Fact] public async Task GetCategoryContainersWithForumsMapsCatsWithUnCatForums() { var service = GetService(); var categories = new List { new Category {CategoryID = 1, SortOrder = 5}, new Category {CategoryID = 2, SortOrder = 1}, new Category {CategoryID = 3, SortOrder = 3} }; _mockCategoryRepo.GetAll().Returns(Task.FromResult(categories)); var forums = new List { new Forum {ForumID = 1, CategoryID = null}, new Forum {ForumID = 2, CategoryID = categories[0].CategoryID, SortOrder = 3}, new Forum {ForumID = 3, CategoryID = categories[0].CategoryID, SortOrder = 1}, new Forum {ForumID = 4, CategoryID = categories[0].CategoryID, SortOrder = 7}, new Forum {ForumID = 5, CategoryID = categories[0].CategoryID, SortOrder = 5}, new Forum {ForumID = 6, CategoryID = categories[2].CategoryID} }; _mockForumRepo.GetAll().Returns(forums); var result = await service.GetCategoryContainersWithForums(); Assert.Equal(0, result[0].Category.CategoryID); Assert.Equal(2, result[1].Category.CategoryID); Assert.Equal(3, result[2].Category.CategoryID); Assert.Equal(1, result[3].Category.CategoryID); } [Fact] public async Task GetCategoryContainersWithForumsMapsCatsWithoutUnCatForums() { var service = GetService(); var categories = new List { new Category {CategoryID = 1, SortOrder = 5}, new Category {CategoryID = 2, SortOrder = 1}, new Category {CategoryID = 3, SortOrder = 3} }; _mockCategoryRepo.GetAll().Returns(Task.FromResult(categories)); var forums = new List { new Forum {ForumID = 2, CategoryID = categories[0].CategoryID, SortOrder = 3}, new Forum {ForumID = 3, CategoryID = categories[0].CategoryID, SortOrder = 1}, new Forum {ForumID = 4, CategoryID = categories[0].CategoryID, SortOrder = 7}, new Forum {ForumID = 5, CategoryID = categories[0].CategoryID, SortOrder = 5}, new Forum {ForumID = 6, CategoryID = categories[2].CategoryID} }; _mockForumRepo.GetAll().Returns(forums); var result = await service.GetCategoryContainersWithForums(); Assert.Equal(2, result[0].Category.CategoryID); Assert.Equal(3, result[1].Category.CategoryID); Assert.Equal(1, result[2].Category.CategoryID); } [Fact] public async Task GetCategoryContainersWithForumsMapsForums() { var service = GetService(); var categories = new List { new Category {CategoryID = 1, SortOrder = 5}, new Category {CategoryID = 2, SortOrder = 1}, new Category {CategoryID = 3, SortOrder = 3} }; _mockCategoryRepo.GetAll().Returns(Task.FromResult(categories)); var forums = new List { new Forum {ForumID = 1, CategoryID = null, SortOrder = 3}, new Forum {ForumID = 2, CategoryID = categories[0].CategoryID, SortOrder = 3}, new Forum {ForumID = 3, CategoryID = categories[0].CategoryID, SortOrder = 1}, new Forum {ForumID = 4, CategoryID = categories[0].CategoryID, SortOrder = 7}, new Forum {ForumID = 5, CategoryID = categories[0].CategoryID, SortOrder = 5}, new Forum {ForumID = 6, CategoryID = categories[2].CategoryID}, new Forum {ForumID = 7, CategoryID = null, SortOrder = 1}, }; _mockForumRepo.GetAll().Returns(forums); var result = await service.GetCategoryContainersWithForums(); Assert.Equal(7, result[0].Forums.ToArray()[0].ForumID); Assert.Equal(1, result[0].Forums.ToArray()[1].ForumID); Assert.Equal(3, result[3].Forums.ToArray()[0].ForumID); Assert.Equal(2, result[3].Forums.ToArray()[1].ForumID); Assert.Equal(5, result[3].Forums.ToArray()[2].ForumID); Assert.Equal(4, result[3].Forums.ToArray()[3].ForumID); Assert.Equal(6, result[2].Forums.ToArray()[0].ForumID); } [Fact] public void MapTopicContainerForQAMapsBaseProperties() { var topicContainer = new TopicContainer { Forum = new Forum { ForumID = 1 }, Topic = new Topic { TopicID = 2 }, Posts = new List {new Post { PostID = 123, IsFirstInTopic = true }}, PagerContext = new PagerContext(), PermissionContext = new ForumPermissionContext(), Signatures = new Dictionary(), Avatars = new Dictionary(), VotedPostIDs = new List(), TopicState = new TopicState() }; var service = GetService(); var result = service.MapTopicContainerForQA(topicContainer); Assert.Same(topicContainer.Forum, result.Forum); Assert.Same(topicContainer.Topic, result.Topic); Assert.Same(topicContainer.Posts, result.Posts); Assert.Same(topicContainer.PagerContext, result.PagerContext); Assert.Same(topicContainer.PermissionContext, result.PermissionContext); Assert.Same(topicContainer.Signatures, result.Signatures); Assert.Same(topicContainer.Avatars, result.Avatars); Assert.Same(topicContainer.VotedPostIDs, result.VotedPostIDs); Assert.Same(topicContainer.TopicState, result.TopicState); } [Fact] public void MapTopicContainerGrabsFirstPostForQuestion() { var posts = new List { new Post {PostID = 1}, new Post{PostID = 2, IsFirstInTopic = true} }; var topicContainer = new TopicContainer {Posts = posts, Topic = new Topic { TopicID = 123 }}; var service = GetService(); var result = service.MapTopicContainerForQA(topicContainer); Assert.Equal(2, result.QuestionPostWithComments.Post.PostID); } [Fact] public void MapTopicContainerThrowsWithNoFirstInTopicPost() { var posts = new List { new Post { PostID = 1 }, new Post { PostID = 2 } }; var topicContainer = new TopicContainer { Posts = posts, Topic = new Topic { TopicID = 123 } }; var service = GetService(); Assert.Throws(() => service.MapTopicContainerForQA(topicContainer)); } [Fact] public void MapTopicContainerThrowsWithMoreThanOneFirstInTopicPost() { var posts = new List { new Post { PostID = 1, IsFirstInTopic = true}, new Post { PostID = 2, IsFirstInTopic = true} }; var topicContainer = new TopicContainer { Posts = posts, Topic = new Topic { TopicID = 123 } }; var service = GetService(); Assert.Throws(() => service.MapTopicContainerForQA(topicContainer)); } [Fact] public void MapTopicContainerSetsQuestionsWithNoParentAsAnswers() { var post1 = new Post { PostID = 1, ParentPostID = 0}; var post2 = new Post { PostID = 2, IsFirstInTopic = true}; var post3 = new Post { PostID = 3, ParentPostID = 2}; var post4 = new Post { PostID = 4, ParentPostID = 1}; var post5 = new Post { PostID = 5, ParentPostID = 3}; var posts = new List {post1, post2, post3, post4, post5}; var topicContainer = new TopicContainer { Posts = posts, Topic = new Topic { TopicID = 1234 } }; var service = GetService(); var result = service.MapTopicContainerForQA(topicContainer); Assert.Single(result.AnswersWithComments); Assert.Same(post1, result.AnswersWithComments[0].Post); } [Fact] public void MapTopicContainerMapsCommentsToParentQuestionsAndAnswers() { var post1 = new Post { PostID = 1, ParentPostID = 0 }; var post2 = new Post { PostID = 2, IsFirstInTopic = true }; var post3 = new Post { PostID = 3, ParentPostID = 0 }; var post4 = new Post { PostID = 4, ParentPostID = 1 }; var post5 = new Post { PostID = 5, ParentPostID = 2 }; var post6 = new Post { PostID = 6, ParentPostID = 3 }; var post7 = new Post { PostID = 7, ParentPostID = 3 }; var posts = new List { post1, post2, post3, post4, post5, post6, post7 }; var topicContainer = new TopicContainer { Posts = posts, Topic = new Topic { TopicID = 1234 } }; var service = GetService(); var result = service.MapTopicContainerForQA(topicContainer); Assert.True(result.AnswersWithComments[0].Children.Count == 1); Assert.Contains(post4, result.AnswersWithComments[0].Children); Assert.True(result.AnswersWithComments[1].Children.Count == 2); Assert.Contains(post6, result.AnswersWithComments[1].Children); Assert.Contains(post7, result.AnswersWithComments[1].Children); } [Fact] public void MapTopicContainerMapsCommentsToQuestion() { var post1 = new Post { PostID = 1, ParentPostID = 0 }; var post2 = new Post { PostID = 2, IsFirstInTopic = true }; var post3 = new Post { PostID = 3, ParentPostID = 0 }; var post4 = new Post { PostID = 4, ParentPostID = 1 }; var post5 = new Post { PostID = 5, ParentPostID = 2 }; var post6 = new Post { PostID = 6, ParentPostID = 2 }; var post7 = new Post { PostID = 7, ParentPostID = 3 }; var posts = new List { post1, post2, post3, post4, post5, post6, post7 }; var topicContainer = new TopicContainer { Posts = posts, Topic = new Topic { TopicID = 1234 } }; var service = GetService(); var result = service.MapTopicContainerForQA(topicContainer); Assert.True(result.QuestionPostWithComments.Children.Count == 2); Assert.Contains(post5, result.QuestionPostWithComments.Children); Assert.Contains(post6, result.QuestionPostWithComments.Children); } [Fact] public void MapTopicContainerOrdersAnswersByVoteThenDate() { var post1 = new Post { PostID = 1, IsFirstInTopic = true }; var post2 = new Post { PostID = 2, Votes = 7, PostTime = new DateTime(2000, 1, 1) }; var post3 = new Post { PostID = 3, Votes = 7, PostTime = new DateTime(2000, 2, 1) }; var post4 = new Post { PostID = 4, Votes = 2 }; var post5 = new Post { PostID = 5, Votes = 3 }; var post6 = new Post { PostID = 6, Votes = 8 }; var post7 = new Post { PostID = 7, Votes = 5 }; var posts = new List { post1, post2, post3, post4, post5, post6, post7 }; var topic = new Topic { TopicID = 123, AnswerPostID = null }; var topicContainer = new TopicContainer { Posts = posts, Topic = topic }; var service = GetService(); var result = service.MapTopicContainerForQA(topicContainer); Assert.Same(post6, result.AnswersWithComments[0].Post); Assert.Same(post3, result.AnswersWithComments[1].Post); Assert.Same(post2, result.AnswersWithComments[2].Post); Assert.Same(post7, result.AnswersWithComments[3].Post); Assert.Same(post5, result.AnswersWithComments[4].Post); Assert.Same(post4, result.AnswersWithComments[5].Post); } [Fact] public void MapTopicContainerOrdersAnswersByAnswerThenVoteThenDate() { var post1 = new Post { PostID = 1, IsFirstInTopic = true }; var post2 = new Post { PostID = 2, Votes = 7, PostTime = new DateTime(2000, 1, 1) }; var post3 = new Post { PostID = 3, Votes = 7, PostTime = new DateTime(2000, 2, 1) }; var post4 = new Post { PostID = 4, Votes = 2 }; var post5 = new Post { PostID = 5, Votes = 3 }; var post6 = new Post { PostID = 6, Votes = 8 }; var post7 = new Post { PostID = 7, Votes = 5 }; var posts = new List { post1, post2, post3, post4, post5, post6, post7 }; var topic = new Topic { TopicID = 123, AnswerPostID = 5}; var topicContainer = new TopicContainer { Posts = posts, Topic = topic }; var service = GetService(); var result = service.MapTopicContainerForQA(topicContainer); Assert.Same(post5, result.AnswersWithComments[0].Post); Assert.Same(post6, result.AnswersWithComments[1].Post); Assert.Same(post3, result.AnswersWithComments[2].Post); Assert.Same(post2, result.AnswersWithComments[3].Post); Assert.Same(post7, result.AnswersWithComments[4].Post); Assert.Same(post4, result.AnswersWithComments[5].Post); } [Fact] public void MapTopicContainerDoesNotMapCommentsForTopQuestionAsReplies() { var post1 = new Post { PostID = 1, ParentPostID = 0 }; var post2 = new Post { PostID = 2, IsFirstInTopic = true }; var post3 = new Post { PostID = 3, ParentPostID = 0 }; var post4 = new Post { PostID = 4, ParentPostID = 1 }; var post5 = new Post { PostID = 5, ParentPostID = 2 }; var post6 = new Post { PostID = 6, ParentPostID = 3 }; var post7 = new Post { PostID = 7, ParentPostID = 3 }; var posts = new List { post1, post2, post3, post4, post5, post6, post7 }; var topicContainer = new TopicContainer { Posts = posts, Topic = new Topic { TopicID = 1234 } }; var service = GetService(); var result = service.MapTopicContainerForQA(topicContainer); Assert.DoesNotContain(result.AnswersWithComments, x => x.Post.PostID == post5.PostID); } [Fact] public void MapTopicContainerMapsLastReadTimeToQuestionAndAnswerSets() { var post1 = new Post { PostID = 1, ParentPostID = 0 }; var post2 = new Post { PostID = 2, IsFirstInTopic = true }; var post3 = new Post { PostID = 3, ParentPostID = 0 }; var post4 = new Post { PostID = 4, ParentPostID = 1 }; var post5 = new Post { PostID = 5, ParentPostID = 2 }; var post6 = new Post { PostID = 6, ParentPostID = 3 }; var post7 = new Post { PostID = 7, ParentPostID = 3 }; var posts = new List { post1, post2, post3, post4, post5, post6, post7 }; var lastRead = new DateTime(2000, 1, 1); var topicContainer = new TopicContainer { Posts = posts, Topic = new Topic { TopicID = 1234 }, LastReadTime = lastRead }; var service = GetService(); var result = service.MapTopicContainerForQA(topicContainer); Assert.Equal(lastRead, result.AnswersWithComments[0].LastReadTime); Assert.Equal(lastRead, result.AnswersWithComments[1].LastReadTime); Assert.Equal(lastRead, result.QuestionPostWithComments.LastReadTime); } public class ModifyForumRoles : ForumServiceTests { [Fact] public async Task ThrowsIfNoForumMatch() { var service = GetService(); _mockForumRepo.Get(Arg.Any()).Returns((Forum) null); await Assert.ThrowsAsync(async () => await service.ModifyForumRoles(new ModifyForumRolesContainer())); } private async Task> CallSetup(ModifyForumRolesType modifyType) { var service = GetService(); var forum = new Forum { ForumID = 123 }; var role = "role"; _mockForumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); await service.ModifyForumRoles(new ModifyForumRolesContainer { ForumID = forum.ForumID, ModifyType = modifyType, Role = role }); return Tuple.Create(forum.ForumID, role); } [Fact] public async Task AddPostCallsRepo() { var (forumID, role) = await CallSetup(ModifyForumRolesType.AddPost); await _mockForumRepo.Received().AddPostRole(forumID, role); } [Fact] public async Task RemovePostCallsRepo() { var (forumID, role) = await CallSetup(ModifyForumRolesType.RemovePost); await _mockForumRepo.Received().RemovePostRole(forumID, role); } [Fact] public async Task AddViewCallsRepo() { var (forumID, role) = await CallSetup(ModifyForumRolesType.AddView); await _mockForumRepo.Received().AddViewRole(forumID, role); } [Fact] public async Task RemoveViewCallsRepo() { var (forumID, role) = await CallSetup(ModifyForumRolesType.RemoveView); await _mockForumRepo.Received().RemoveViewRole(forumID, role); } [Fact] public async Task RemoveAllPostCallsRepo() { var (forumID, _) = await CallSetup(ModifyForumRolesType.RemoveAllPost); await _mockForumRepo.Received().RemoveAllPostRoles(forumID); } [Fact] public async Task RemoveAllViewCallsRepo() { var (forumID, _) = await CallSetup(ModifyForumRolesType.RemoveAllView); await _mockForumRepo.Received().RemoveAllViewRoles(forumID); } } } ================================================ FILE: src/PopForums.Test/Services/ImageServiceTests.cs ================================================ namespace PopForums.Test.Services; public class ImageServiceTests { private IUserImageRepository _imageRepo; private IUserAvatarRepository _avatarRepo; private IProfileService _profileService; private IUserRepository _userRepo; private ISettingsManager _settingsManager; private ImageService GetService() { _imageRepo = Substitute.For(); _avatarRepo = Substitute.For(); _profileService = Substitute.For(); _userRepo = Substitute.For(); _settingsManager = Substitute.For(); return new ImageService(_avatarRepo, _imageRepo, _profileService, _userRepo, _settingsManager); } [Fact] public async Task GetAvatar() { var service = GetService(); var streamResponse = Substitute.For(); _avatarRepo.GetImageStream(1).Returns(Task.FromResult(streamResponse)); var result = await service.GetAvatarImageStream(1); Assert.Same(streamResponse, result); } [Fact] public async Task GetUserImage() { var service = GetService(); var streamResponse = Substitute.For(); _imageRepo.GetImageStream(1).Returns(Task.FromResult(streamResponse)); var result = await service.GetUserImageStream(1); Assert.Same(streamResponse, result); } } ================================================ FILE: src/PopForums.Test/Services/LastReadServiceTests.cs ================================================ namespace PopForums.Test.Services; public class LastReadServiceTests { private LastReadService GetService() { _lastReadRepo = Substitute.For(); _postRepo = Substitute.For(); return new LastReadService(_lastReadRepo, _postRepo); } private ILastReadRepository _lastReadRepo; private IPostRepository _postRepo; [Fact] public async Task MarkForumReadSetsReadTime() { var service = GetService(); var forum = new Forum { ForumID = 123 }; var user = new User { UserID = 456 }; await service.MarkForumRead(user, forum); await _lastReadRepo.Received(1).SetForumRead(user.UserID, forum.ForumID, Arg.Any()); } [Fact] public async Task MarkForumReadDeletesOldTopicReadTimes() { var service = GetService(); var forum = new Forum { ForumID = 123 }; var user = new User { UserID = 456 }; await service.MarkForumRead(user, forum); await _lastReadRepo.Received(1).DeleteTopicReadsInForum(user.UserID, forum.ForumID); } [Fact] public async Task MarkTopicReadThrowsWithoutUser() { var service = GetService(); await Assert.ThrowsAsync(async () => await service.MarkTopicRead(null, new Topic { TopicID = 1 })); } [Fact] public async Task MarkTopicReadThrowsWithoutTopic() { var service = GetService(); await Assert.ThrowsAsync(async () => await service.MarkTopicRead(new User(), null)); } [Fact] public async Task MarkAllForumReadThrowsWithoutUser() { var service = GetService(); await Assert.ThrowsAsync(async () => await service.MarkAllForumsRead(null)); } [Fact] public async Task MarkForumReadThrowsWithoutUser() { var service = GetService(); await Assert.ThrowsAsync(async () => await service.MarkForumRead(null, new Forum { ForumID = 1 })); } [Fact] public async Task MarkForumReadThrowsWithoutForum() { var service = GetService(); await Assert.ThrowsAsync(async () => await service.MarkForumRead(new User(), null)); } [Fact] public async Task MarkAllForumReadSetsReadTimes() { var service = GetService(); var user = new User { UserID = 456 }; await service.MarkAllForumsRead(user); await _lastReadRepo.Received(1).SetAllForumsRead(user.UserID, Arg.Any()); } [Fact] public async Task MarkAllForumReadDeletesAllOldTopicReadTimes() { var service = GetService(); var user = new User { UserID = 456 }; await service.MarkAllForumsRead(user); await _lastReadRepo.Received(1).DeleteAllTopicReads(user.UserID); } [Fact] public async Task ForumReadStatusForNoUser() { var service = GetService(); var forum1 = new Forum { ForumID = 1 }; var forum2 = new Forum { ForumID = 2, IsArchived = true }; var forum3 = new Forum { ForumID = 3 }; var container = new CategorizedForumContainer(new List(), new[] { forum1, forum2, forum3 }); await service.GetForumReadStatus(null, container); Assert.Equal(3, container.ReadStatusLookup.Count); Assert.Equal(ReadStatus.NoNewPosts, container.ReadStatusLookup[1]); Assert.Equal(ReadStatus.NoNewPosts | ReadStatus.Closed, container.ReadStatusLookup[2]); Assert.Equal(ReadStatus.NoNewPosts, container.ReadStatusLookup[3]); } [Fact] public async Task ForumReadStatusUserNewPosts() { var service = GetService(); var forum = new Forum { ForumID = 1, LastPostTime = new DateTime(2000, 1, 1, 5, 0, 0) }; var user = new User { UserID = 2 }; _lastReadRepo.GetLastReadTimesForForums(2).Returns(Task.FromResult(new Dictionary { { 1, new DateTime(2000, 1, 1, 3, 0, 0) } })); var container = new CategorizedForumContainer(new List(), new[] { forum }); await service.GetForumReadStatus(user, container); Assert.Single(container.ReadStatusLookup); Assert.Equal(ReadStatus.NewPosts, container.ReadStatusLookup[1]); } [Fact] public async Task ForumReadStatusUserNewPostsButNoTopicRecords() { var service = GetService(); var forum = new Forum { ForumID = 1, LastPostTime = new DateTime(2000, 1, 1, 5, 0, 0) }; var user = new User { UserID = 2 }; _lastReadRepo.GetLastReadTimesForForums(2).Returns(Task.FromResult(new Dictionary())); _lastReadRepo.GetLastReadTimesForForum(user.UserID, forum.ForumID).Returns(new DateTime(2000, 1, 1, 3, 0, 0)); var container = new CategorizedForumContainer(new List(), new[] { forum }); await service.GetForumReadStatus(user, container); Assert.Single(container.ReadStatusLookup); Assert.Equal(ReadStatus.NewPosts, container.ReadStatusLookup[1]); } [Fact] public async Task ForumReadStatusUserNewPostsNoLastReadRecords() { var service = GetService(); var forum = new Forum { ForumID = 1, LastPostTime = new DateTime(2000, 1, 1, 5, 0, 0) }; var user = new User { UserID = 2 }; _lastReadRepo.GetLastReadTimesForForums(2).Returns(Task.FromResult(new Dictionary())); var container = new CategorizedForumContainer(new List(), new[] { forum }); await service.GetForumReadStatus(user, container); Assert.Single(container.ReadStatusLookup); Assert.Equal(ReadStatus.NewPosts, container.ReadStatusLookup[1]); } [Fact] public async Task ForumReadStatusUserNoNewPosts() { var service = GetService(); var forum = new Forum { ForumID = 1, LastPostTime = new DateTime(2000, 1, 1, 5, 0, 0) }; var user = new User { UserID = 2 }; _lastReadRepo.GetLastReadTimesForForums(2).Returns(Task.FromResult(new Dictionary { { 1, new DateTime(2000, 1, 1, 7, 0, 0) } })); var container = new CategorizedForumContainer(new List(), new[] { forum }); await service.GetForumReadStatus(user, container); Assert.Single(container.ReadStatusLookup); Assert.Equal(ReadStatus.NoNewPosts, container.ReadStatusLookup[1]); } [Fact] public async Task ForumReadStatusUserNewPostsArchived() { var service = GetService(); var forum = new Forum { ForumID = 1, LastPostTime = new DateTime(2000, 1, 1, 5, 0, 0), IsArchived = true }; var user = new User { UserID = 2 }; _lastReadRepo.GetLastReadTimesForForums(2).Returns(Task.FromResult(new Dictionary { { 1, new DateTime(2000, 1, 1, 3, 0, 0) } })); var container = new CategorizedForumContainer(new List(), new[] { forum }); await service.GetForumReadStatus(user, container); Assert.Single(container.ReadStatusLookup); Assert.Equal(ReadStatus.NewPosts | ReadStatus.Closed, container.ReadStatusLookup[1]); } [Fact] public async Task ForumReadStatusUserNoNewPostsArchived() { var service = GetService(); var forum = new Forum { ForumID = 1, LastPostTime = new DateTime(2000, 1, 1, 5, 0, 0), IsArchived = true }; var user = new User { UserID = 2 }; _lastReadRepo.GetLastReadTimesForForums(2).Returns(Task.FromResult(new Dictionary { { 1, new DateTime(2000, 1, 1, 7, 0, 0) } })); var container = new CategorizedForumContainer(new List(), new[] { forum }); await service.GetForumReadStatus(user, container); Assert.Single(container.ReadStatusLookup); Assert.Equal(ReadStatus.NoNewPosts | ReadStatus.Closed, container.ReadStatusLookup[1]); } [Fact] public async Task TopicReadStatusForNoUser() { var service = GetService(); var container = new PagedTopicContainer(); container.Topics = new List {new Topic { TopicID = 1 }, new Topic { TopicID = 2, IsClosed = true}, new Topic { TopicID = 3, IsPinned = true}}; await service.GetTopicReadStatus(null, container); Assert.Equal(3, container.ReadStatusLookup.Count); Assert.Equal(ReadStatus.NoNewPosts | ReadStatus.Open | ReadStatus.NotPinned, container.ReadStatusLookup[1]); Assert.Equal(ReadStatus.NoNewPosts | ReadStatus.Closed | ReadStatus.NotPinned, container.ReadStatusLookup[2]); Assert.Equal(ReadStatus.NoNewPosts | ReadStatus.Open | ReadStatus.Pinned, container.ReadStatusLookup[3]); } [Fact] public async Task TopicReadStatusWithUserNewNoForumRecordNoTopicRecord() { var service = GetService(); var container = new PagedTopicContainer(); container.Topics = new List { new Topic { TopicID = 1, ForumID = 2, LastPostTime = new DateTime(2000, 1, 1, 5, 0, 0) } }; var user = new User { UserID = 123 }; _lastReadRepo.GetLastReadTimesForForums(user.UserID).Returns(Task.FromResult(new Dictionary())); _lastReadRepo.GetLastReadTimesForTopics(user.UserID, Arg.Is>(x => x.SequenceEqual(new[] { 1 }))).Returns(Task.FromResult(new Dictionary())); await service.GetTopicReadStatus(user, container); Assert.Equal(ReadStatus.NewPosts | ReadStatus.Open | ReadStatus.NotPinned, container.ReadStatusLookup[1]); } [Fact] public async Task TopicReadStatusWithUserNewNoForumRecordWithTopicRecord() { var service = GetService(); var container = new PagedTopicContainer(); container.Topics = new List { new Topic { TopicID = 1, ForumID = 2, LastPostTime = new DateTime(2000, 1, 1, 5, 0, 0) } }; var user = new User { UserID = 123 }; _lastReadRepo.GetLastReadTimesForForums(user.UserID).Returns(Task.FromResult(new Dictionary())); _lastReadRepo.GetLastReadTimesForTopics(user.UserID, Arg.Is>(x => x.SequenceEqual(new[] { 1 }))).Returns(Task.FromResult(new Dictionary { { 1, new DateTime(2000, 1, 1, 3, 0, 0) } })); await service.GetTopicReadStatus(user, container); Assert.Equal(ReadStatus.NewPosts | ReadStatus.Open | ReadStatus.NotPinned, container.ReadStatusLookup[1]); } [Fact] public async Task TopicReadStatusWithUserNewWithForumRecordNoTopicRecord() { var service = GetService(); var container = new PagedTopicContainer(); container.Topics = new List { new Topic { TopicID = 1, ForumID = 2, LastPostTime = new DateTime(2000, 1, 1, 5, 0, 0) } }; var user = new User { UserID = 123 }; _lastReadRepo.GetLastReadTimesForForums(user.UserID).Returns(Task.FromResult(new Dictionary { { 2, new DateTime(2000, 1, 1, 2, 0, 0) } })); _lastReadRepo.GetLastReadTimesForTopics(user.UserID, Arg.Is>(x => x.SequenceEqual(new[] { 1 }))).Returns(Task.FromResult(new Dictionary())); await service.GetTopicReadStatus(user, container); Assert.Equal(ReadStatus.NewPosts | ReadStatus.Open | ReadStatus.NotPinned, container.ReadStatusLookup[1]); } [Fact] public async Task TopicReadStatusWithUserNewWithForumRecordWithTopicRecord() { var service = GetService(); var container = new PagedTopicContainer(); container.Topics = new List { new Topic { TopicID = 1, ForumID = 2, LastPostTime = new DateTime(2000, 1, 1, 5, 0, 0) } }; var user = new User { UserID = 123 }; _lastReadRepo.GetLastReadTimesForForums(user.UserID).Returns(Task.FromResult(new Dictionary { { 2, new DateTime(2000, 1, 1, 2, 0, 0) } })); _lastReadRepo.GetLastReadTimesForTopics(user.UserID, Arg.Is>(x => x.SequenceEqual(new[] { 1 }))).Returns(Task.FromResult(new Dictionary { { 1, new DateTime(2000, 1, 1, 3, 0, 0) } })); await service.GetTopicReadStatus(user, container); Assert.Equal(ReadStatus.NewPosts | ReadStatus.Open | ReadStatus.NotPinned, container.ReadStatusLookup[1]); } [Fact] public async Task TopicReadStatusWithUserNotNewWithForumRecordNoTopicRecord() { var service = GetService(); var container = new PagedTopicContainer(); container.Topics = new List { new Topic { TopicID = 1, ForumID = 2, LastPostTime = new DateTime(2000, 1, 1, 5, 0, 0) } }; var user = new User { UserID = 123 }; _lastReadRepo.GetLastReadTimesForForums(user.UserID).Returns(Task.FromResult(new Dictionary { { 2, new DateTime(2000, 1, 1, 7, 0, 0) } })); _lastReadRepo.GetLastReadTimesForTopics(user.UserID, Arg.Is>(x => x.SequenceEqual(new[] { 1 }))).Returns(Task.FromResult(new Dictionary())); await service.GetTopicReadStatus(user, container); Assert.Equal(ReadStatus.NoNewPosts | ReadStatus.Open | ReadStatus.NotPinned, container.ReadStatusLookup[1]); } [Fact] public async Task TopicReadStatusWithUserNotNewNoForumRecordWithTopicRecord() { var service = GetService(); var container = new PagedTopicContainer(); container.Topics = new List { new Topic { TopicID = 1, ForumID = 2, LastPostTime = new DateTime(2000, 1, 1, 5, 0, 0) } }; var user = new User { UserID = 123 }; _lastReadRepo.GetLastReadTimesForForums(user.UserID).Returns(Task.FromResult(new Dictionary())); _lastReadRepo.GetLastReadTimesForTopics(user.UserID, Arg.Is>(x => x.SequenceEqual(new[] { 1 }))).Returns(Task.FromResult(new Dictionary { { 1, new DateTime(2000, 1, 1, 7, 0, 0) } })); await service.GetTopicReadStatus(user, container); Assert.Equal(ReadStatus.NoNewPosts | ReadStatus.Open | ReadStatus.NotPinned, container.ReadStatusLookup[1]); } [Fact] public async Task TopicReadStatusWithUserNotNewWithForumRecordWithTopicRecordForumNewer() { var service = GetService(); var container = new PagedTopicContainer(); container.Topics = new List { new Topic { TopicID = 1, ForumID = 2, LastPostTime = new DateTime(2000, 1, 1, 5, 0, 0) } }; var user = new User { UserID = 123 }; _lastReadRepo.GetLastReadTimesForForums(user.UserID).Returns(Task.FromResult(new Dictionary { { 2, new DateTime(2000, 1, 1, 7, 0, 0) } })); _lastReadRepo.GetLastReadTimesForTopics(user.UserID, Arg.Is>(x => x.SequenceEqual(new[] { 1 }))).Returns(Task.FromResult(new Dictionary { { 1, new DateTime(2000, 1, 1, 3, 0, 0) } })); await service.GetTopicReadStatus(user, container); Assert.Equal(ReadStatus.NoNewPosts | ReadStatus.Open | ReadStatus.NotPinned, container.ReadStatusLookup[1]); } [Fact] public async Task TopicReadStatusWithUserNotNewWithForumRecordWithTopicRecordTopicNewer() { var service = GetService(); var container = new PagedTopicContainer(); container.Topics = new List { new Topic { TopicID = 1, ForumID = 2, LastPostTime = new DateTime(2000, 1, 1, 5, 0, 0) } }; var user = new User { UserID = 123 }; _lastReadRepo.GetLastReadTimesForForums(user.UserID).Returns(Task.FromResult(new Dictionary { { 2, new DateTime(2000, 1, 1, 3, 0, 0) } })); _lastReadRepo.GetLastReadTimesForTopics(user.UserID, Arg.Is>(x => x.SequenceEqual(new[] { 1 }))).Returns(Task.FromResult(new Dictionary { { 1, new DateTime(2000, 1, 1, 7, 0, 0) } })); await service.GetTopicReadStatus(user, container); Assert.Equal(ReadStatus.NoNewPosts | ReadStatus.Open | ReadStatus.NotPinned, container.ReadStatusLookup[1]); } [Fact] public async Task TopicReadStatusWithUserOpenNewPinned() { var service = GetService(); var container = new PagedTopicContainer(); container.Topics = new List { new Topic { TopicID = 1, ForumID = 2, IsPinned = true, LastPostTime = new DateTime(2000, 1, 1, 5, 0, 0) } }; var user = new User { UserID = 123 }; _lastReadRepo.GetLastReadTimesForForums(user.UserID).Returns(Task.FromResult(new Dictionary { { 2, new DateTime(2000, 1, 1, 3, 0, 0) } })); _lastReadRepo.GetLastReadTimesForTopics(user.UserID, Arg.Is>(x => x.SequenceEqual(new[] { 1 }))).Returns(Task.FromResult(new Dictionary())); await service.GetTopicReadStatus(user, container); Assert.Equal(ReadStatus.NewPosts | ReadStatus.Open | ReadStatus.Pinned, container.ReadStatusLookup[1]); } [Fact] public async Task TopicReadStatusWithUserOpenNewNotPinned() { var service = GetService(); var container = new PagedTopicContainer(); container.Topics = new List { new Topic { TopicID = 1, ForumID = 2, LastPostTime = new DateTime(2000, 1, 1, 5, 0, 0) } }; var user = new User { UserID = 123 }; _lastReadRepo.GetLastReadTimesForForums(user.UserID).Returns(Task.FromResult(new Dictionary { { 2, new DateTime(2000, 1, 1, 3, 0, 0) } })); _lastReadRepo.GetLastReadTimesForTopics(user.UserID, Arg.Is>(x => x.SequenceEqual(new[] { 1 }))).Returns(Task.FromResult(new Dictionary())); await service.GetTopicReadStatus(user, container); Assert.Equal(ReadStatus.NewPosts | ReadStatus.Open | ReadStatus.NotPinned, container.ReadStatusLookup[1]); } [Fact] public async Task TopicReadStatusWithUserOpenNotNewPinned() { var service = GetService(); var container = new PagedTopicContainer(); container.Topics = new List { new Topic { TopicID = 1, ForumID = 2, IsPinned = true, LastPostTime = new DateTime(2000, 1, 1, 5, 0, 0) } }; var user = new User { UserID = 123 }; _lastReadRepo.GetLastReadTimesForForums(user.UserID).Returns(Task.FromResult(new Dictionary { { 2, new DateTime(2000, 1, 1, 7, 0, 0) } })); _lastReadRepo.GetLastReadTimesForTopics(user.UserID, Arg.Is>(x => x.SequenceEqual(new[] { 1 }))).Returns(Task.FromResult(new Dictionary())); await service.GetTopicReadStatus(user, container); Assert.Equal(ReadStatus.NoNewPosts | ReadStatus.Open | ReadStatus.Pinned, container.ReadStatusLookup[1]); } [Fact] public async Task TopicReadStatusWithUserOpenNotNewNotPinned() { var service = GetService(); var container = new PagedTopicContainer(); container.Topics = new List { new Topic { TopicID = 1, ForumID = 2, LastPostTime = new DateTime(2000, 1, 1, 5, 0, 0) } }; var user = new User { UserID = 123 }; _lastReadRepo.GetLastReadTimesForForums(user.UserID).Returns(Task.FromResult(new Dictionary { { 2, new DateTime(2000, 1, 1, 7, 0, 0) } })); _lastReadRepo.GetLastReadTimesForTopics(user.UserID, Arg.Is>(x => x.SequenceEqual(new[] { 1 }))).Returns(Task.FromResult(new Dictionary())); await service.GetTopicReadStatus(user, container); Assert.Equal(ReadStatus.NoNewPosts | ReadStatus.Open | ReadStatus.NotPinned, container.ReadStatusLookup[1]); } [Fact] public async Task TopicReadStatusWithUserClosedNewPinned() { var service = GetService(); var container = new PagedTopicContainer(); container.Topics = new List { new Topic { TopicID = 1, ForumID = 2, IsClosed = true, IsPinned = true, LastPostTime = new DateTime(2000, 1, 1, 5, 0, 0) } }; var user = new User { UserID = 123 }; _lastReadRepo.GetLastReadTimesForForums(user.UserID).Returns(Task.FromResult(new Dictionary { { 2, new DateTime(2000, 1, 1, 3, 0, 0) } })); _lastReadRepo.GetLastReadTimesForTopics(user.UserID, Arg.Is>(x => x.SequenceEqual(new[] { 1 }))).Returns(new Dictionary()); await service.GetTopicReadStatus(user, container); Assert.Equal(ReadStatus.NewPosts | ReadStatus.Closed | ReadStatus.Pinned, container.ReadStatusLookup[1]); } [Fact] public async Task TopicReadStatusWithUserClosedNewNotPinned() { var service = GetService(); var container = new PagedTopicContainer(); container.Topics = new List { new Topic { TopicID = 1, ForumID = 2, IsClosed = true, LastPostTime = new DateTime(2000, 1, 1, 5, 0, 0) } }; var user = new User { UserID = 123 }; _lastReadRepo.GetLastReadTimesForForums(user.UserID).Returns(Task.FromResult(new Dictionary { { 2, new DateTime(2000, 1, 1, 3, 0, 0) } })); _lastReadRepo.GetLastReadTimesForTopics(user.UserID, Arg.Is>(x => x.SequenceEqual(new[] { 1 }))).Returns(Task.FromResult(new Dictionary())); await service.GetTopicReadStatus(user, container); Assert.Equal(ReadStatus.NewPosts | ReadStatus.Closed | ReadStatus.NotPinned, container.ReadStatusLookup[1]); } [Fact] public async Task TopicReadStatusWithUserClosedNoNewPinned() { var service = GetService(); var container = new PagedTopicContainer(); container.Topics = new List { new Topic { TopicID = 1, ForumID = 2, IsClosed = true, IsPinned = true, LastPostTime = new DateTime(2000, 1, 1, 5, 0, 0) } }; var user = new User { UserID = 123 }; _lastReadRepo.GetLastReadTimesForForums(user.UserID).Returns(Task.FromResult(new Dictionary { { 2, new DateTime(2000, 1, 1, 7, 0, 0) } })); _lastReadRepo.GetLastReadTimesForTopics(user.UserID, Arg.Is>(x => x.SequenceEqual(new[] { 1 }))).Returns(Task.FromResult(new Dictionary())); await service.GetTopicReadStatus(user, container); Assert.Equal(ReadStatus.NoNewPosts | ReadStatus.Closed | ReadStatus.Pinned, container.ReadStatusLookup[1]); } [Fact] public async Task TopicReadStatusWithUserClosedNoNewNotPinned() { var service = GetService(); var container = new PagedTopicContainer(); container.Topics = new List { new Topic { TopicID = 1, ForumID = 2, IsClosed = true, LastPostTime = new DateTime(2000, 1, 1, 5, 0, 0) } }; var user = new User { UserID = 123 }; _lastReadRepo.GetLastReadTimesForForums(user.UserID).Returns(Task.FromResult(new Dictionary { { 2, new DateTime(2000, 1, 1, 7, 0, 0) } })); _lastReadRepo.GetLastReadTimesForTopics(user.UserID, Arg.Is>(x => x.SequenceEqual(new[] { 1 }))).Returns(Task.FromResult(new Dictionary())); await service.GetTopicReadStatus(user, container); Assert.Equal(ReadStatus.NoNewPosts | ReadStatus.Closed | ReadStatus.NotPinned, container.ReadStatusLookup[1]); } [Fact] public async Task MarkTopicReadCallsRepo() { var service = GetService(); var user = new User { UserID = 1 }; var topic = new Topic { TopicID = 2 }; await service.MarkTopicRead(user, topic); await _lastReadRepo.Received(1).SetTopicRead(user.UserID, topic.TopicID, Arg.Any()); } [Fact] public async Task GetLastReadTimeReturnsTopicTimeWhenAvailable() { var service = GetService(); var user = new User { UserID = 1 }; var topic = new Topic { TopicID = 2 }; var lastRead = new DateTime(2010, 1, 1); _lastReadRepo.GetLastReadTimeForTopic(user.UserID, topic.TopicID).Returns(lastRead); var result = await service.GetLastReadTime(user, topic); Assert.Equal(lastRead, result); await _lastReadRepo.DidNotReceive().GetLastReadTimesForForum(Arg.Any(), Arg.Any()); } [Fact] public async Task GetLastReadTimeReturnsForumTimeWhenNoTopicTimeAvailable() { var service = GetService(); var user = new User { UserID = 1 }; var topic = new Topic { TopicID = 2, ForumID = 3}; var lastRead = new DateTime(2010, 1, 1); _lastReadRepo.GetLastReadTimeForTopic(user.UserID, topic.TopicID).Returns((DateTime?)null); _lastReadRepo.GetLastReadTimesForForum(user.UserID, topic.ForumID).Returns(lastRead); var result = await service.GetLastReadTime(user, topic); Assert.Equal(lastRead, result); } } ================================================ FILE: src/PopForums.Test/Services/PostImageCleanupWorkerTests.cs ================================================ using NSubstitute.ExceptionExtensions; namespace PopForums.Test.Services; public class PostImageCleanupWorkerTests { private IPostImageService _postImageService; private IErrorLog _errorLog; private PostImageCleanupWorker GetWorker() { _postImageService = Substitute.For(); _errorLog = Substitute.For(); return new PostImageCleanupWorker(_postImageService, _errorLog); } [Fact] public void NoErrorNoLog() { var worker = GetWorker(); _postImageService.DeleteOldPostImages().Returns(Task.CompletedTask); worker.Execute(); _errorLog.DidNotReceive().Log(Arg.Any(), Arg.Any()); } [Fact] public void LogWhenThrows() { var worker = GetWorker(); _postImageService.DeleteOldPostImages().ThrowsAsync(); worker.Execute(); _errorLog.Received().Log(Arg.Any(),ErrorSeverity.Error); } } ================================================ FILE: src/PopForums.Test/Services/PostImageServiceTests.cs ================================================ namespace PopForums.Test.Services; public class PostImageServiceTests { private IImageService _imageService; private IPostImageRepository _postImageRepository; private IPostImageTempRepository _postImageTempRepository; private ISettingsManager _settingsManager; private ITenantService _tenantService; protected PostImageService GetService() { _imageService = Substitute.For(); _postImageRepository = Substitute.For(); _postImageTempRepository = Substitute.For(); _settingsManager = Substitute.For(); _tenantService = Substitute.For(); return new PostImageService(_imageService, _postImageRepository, _postImageTempRepository, _settingsManager, _tenantService); } public class ProcessImageIsOk : PostImageServiceTests { [Fact] public void ImageIsTooBig() { var service = GetService(); _settingsManager.Current.PostImageMaxkBytes.Returns(1); var array = new byte[1025]; var contentType = "blah"; var result = service.ProcessImageIsOk(array, contentType); Assert.False(result); _imageService.DidNotReceive().ConstrainResize(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), false); } [Fact] public void ImageIsBadContentType() { var service = GetService(); _settingsManager.Current.PostImageMaxkBytes.Returns(1); var array = new byte[1024]; var contentType = "blah"; var result = service.ProcessImageIsOk(array, contentType); Assert.False(result); _imageService.DidNotReceive().ConstrainResize(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), false); } [Fact] public void ImageIsRightSizeJpeg() { var service = GetService(); _settingsManager.Current.PostImageMaxkBytes.Returns(1); var array = new byte[1024]; var contentType = "image/jpeg"; var result = service.ProcessImageIsOk(array, contentType); Assert.True(result); } [Fact] public void ImageIsRightSizeGif() { var service = GetService(); _settingsManager.Current.PostImageMaxkBytes.Returns(1); var array = new byte[1024]; var contentType = "image/gif"; var result = service.ProcessImageIsOk(array, contentType); Assert.True(result); } [Fact] public void ImageIsResized() { var height = 100; var width = 200; var service = GetService(); _settingsManager.Current.PostImageMaxkBytes.Returns(1); _settingsManager.Current.PostImageMaxHeight.Returns(height); _settingsManager.Current.PostImageMaxWidth.Returns(width); var array = new byte[1]; var contentType = "image/jpeg"; var result = service.ProcessImageIsOk(array, contentType); Assert.True(result); _imageService.Received().ConstrainResize(array, width, height, 60, false); } } public class PersistAndGetPayload : PostImageServiceTests { [Fact] public async Task ThrowsWithNoContentType() { var service = GetService(); _settingsManager.Current.PostImageMaxkBytes.Returns(1); service.ProcessImageIsOk(new byte[1], ""); await Assert.ThrowsAsync(() => service.PersistAndGetPayload()); } [Fact] public async Task ThrowsWhenNotOkContentType() { var service = GetService(); _settingsManager.Current.PostImageMaxkBytes.Returns(1); service.ProcessImageIsOk(new byte[1], "blah"); await Assert.ThrowsAsync(() => service.PersistAndGetPayload()); } [Fact] public async Task ThrowsWhenNotOkBytes() { var service = GetService(); _settingsManager.Current.PostImageMaxkBytes.Returns(1); service.ProcessImageIsOk(new byte[1025], "image/jpeg"); await Assert.ThrowsAsync(() => service.PersistAndGetPayload()); } [Fact] public async Task PersistsImageAndTempRecordAndReturnsPayload() { var service = GetService(); var tenantID = "pop"; _tenantService.GetTenant().Returns(tenantID); _settingsManager.Current.PostImageMaxkBytes.Returns(1); var array = new byte[1]; _imageService.ConstrainResize(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), false).Returns(array); var guid = Guid.NewGuid(); var id = guid.ToString(); var payload = new PostImagePersistPayload {ID = id, Url = "neat"}; _postImageRepository.Persist(Arg.Any(), "image/jpeg").Returns(Task.FromResult(payload)); service.ProcessImageIsOk(new byte[1], "image/jpeg"); var result = await service.PersistAndGetPayload(); await _postImageRepository.Received().Persist(array, "image/jpeg"); await _postImageTempRepository.Received().Save(guid, Arg.Any(), tenantID); Assert.Same(payload, result); } } public class DeleteTempRecord : PostImageServiceTests { [Fact] public async Task TempRepoCalledWithGuid() { var service = GetService(); var guid = Guid.NewGuid(); var id = guid.ToString(); await service.DeleteTempRecord(id); await _postImageTempRepository.Received().Delete(guid); } } public class DeleteTempRecords : PostImageServiceTests { [Fact] public async Task TempRepoCalledWithGuidsFoundInText() { var service = GetService(); var guid = Guid.NewGuid(); var id = guid.ToString(); var guid2 = Guid.NewGuid(); var id2 = guid2.ToString(); var guid3 = Guid.NewGuid(); var id3 = guid3.ToString(); var array = new[] {id, id2, id3}; var text = $"all the words {id3} and ids {id} {id2} "; await service.DeleteTempRecords(array, text); await _postImageTempRepository.Received().Delete(guid); await _postImageTempRepository.Received().Delete(guid2); } [Fact] public async Task TempRepoCalledExcludingGuidsNotFoundInText() { var service = GetService(); var guid = Guid.NewGuid(); var id = guid.ToString(); var guid2 = Guid.NewGuid(); var id2 = guid2.ToString(); var guid3 = Guid.NewGuid(); var id3 = guid3.ToString(); var array = new[] { id, id2, id3 }; var text = $"all the words and ids {id} {id3} "; await service.DeleteTempRecords(array, text); await _postImageTempRepository.Received().Delete(guid); await _postImageTempRepository.DidNotReceive().Delete(guid2); await _postImageTempRepository.Received().Delete(guid3); } } public class DeleteOldPostImages : PostImageServiceTests { [Fact] public async Task PostImageRepoCalledForEachEntry() { var service = GetService(); var tenantID = "pop"; var ids = new List {Guid.NewGuid(), Guid.NewGuid()}; _tenantService.GetTenant().Returns(tenantID); _postImageTempRepository.GetOld(Arg.Any()).Returns(Task.FromResult(ids)); await service.DeleteOldPostImages(); await _postImageRepository.Received().DeletePostImageData(ids[0].ToString(), tenantID); await _postImageRepository.Received().DeletePostImageData(ids[1].ToString(), tenantID); } [Fact] public async Task PostImageTempRepoCalledForEachEntry() { var service = GetService(); var tenantID = "pop"; var ids = new List { Guid.NewGuid(), Guid.NewGuid() }; _tenantService.GetTenant().Returns(tenantID); _postImageTempRepository.GetOld(Arg.Any()).Returns(Task.FromResult(ids)); await service.DeleteOldPostImages(); await _postImageTempRepository.Received().Delete(ids[0]); await _postImageTempRepository.Received().Delete(ids[1]); } } } ================================================ FILE: src/PopForums.Test/Services/PostMasterServiceTests.cs ================================================ namespace PopForums.Test.Services; public class PostMasterServiceTests { private PostMasterService GetService() { _textParser = Substitute.For(); _topicRepo = Substitute.For(); _postRepo = Substitute.For(); _forumRepo = Substitute.For(); _profileRepo = Substitute.For(); _eventPublisher = Substitute.For(); _broker = Substitute.For(); _searchIndexQueueRepo = Substitute.For(); _tenantService = Substitute.For(); _subscribedTopicsService = Substitute.For(); _moderationLogService = Substitute.For(); _forumPermissionService = Substitute.For(); _settingsManager = Substitute.For(); _topicViewCountService = Substitute.For(); _postImageService = Substitute.For(); return new PostMasterService(_textParser, _topicRepo, _postRepo, _forumRepo, _profileRepo, _eventPublisher, _broker, _searchIndexQueueRepo, _tenantService, _subscribedTopicsService, _moderationLogService, _forumPermissionService, _settingsManager, _topicViewCountService, _postImageService); } private ITextParsingService _textParser; private ITopicRepository _topicRepo; private IPostRepository _postRepo; private IForumRepository _forumRepo; private IProfileRepository _profileRepo; private IEventPublisher _eventPublisher; private IBroker _broker; private ISearchIndexQueueRepository _searchIndexQueueRepo; private ITenantService _tenantService; private ISubscribedTopicsService _subscribedTopicsService; private IModerationLogService _moderationLogService; private IForumPermissionService _forumPermissionService; private ISettingsManager _settingsManager; private ITopicViewCountService _topicViewCountService; private IPostImageService _postImageService; private async Task DoUpNewTopic() { var forum = new Forum { ForumID = 1 }; var user = GetUser(); const string ip = "127.0.0.1"; const string title = "mah title"; const string text = "mah text"; var newPost = new NewPost { Title = title, FullText = text, ItemID = 1 }; var service = GetService(); _topicRepo.GetUrlNamesThatStartWith("parsed-title").Returns(Task.FromResult(new List())); _textParser.ClientHtmlToHtml("mah text").Returns("parsed text"); _textParser.Censor("mah title").Returns("parsed title"); _postRepo.Create(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), null, Arg.Any(), Arg.Any()).Returns(Task.FromResult(69)); _forumRepo.Get(forum.ForumID).Returns(forum); _forumRepo.GetForumViewRoles(forum.ForumID).Returns(Task.FromResult(new List())); _topicRepo.Create(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(Task.FromResult(111)); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext {UserCanModerate = false, UserCanPost = true, UserCanView = true})); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile())); await service.PostNewTopic(user, newPost, ip, default, x => "", x => ""); return user; } private User GetUser() { var user = Models.UserTest.GetTestUser(); user.Roles = new List(); return user; } public class PostNewTopicTests : PostMasterServiceTests { [Fact] public async Task NoUserReturnsFalseIsSuccess() { var service = GetService(); var result = await service.PostNewTopic(null, new NewPost(), "", "", x => "", x => ""); Assert.False(result.IsSuccessful); } [Fact] public async Task UserWithoutPostPermissionReturnsFalseIsSuccess() { var service = GetService(); var forum = new Forum{ForumID = 1}; _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); var user = GetUser(); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext {DenialReason = Resources.ForumNoPost, UserCanModerate = false, UserCanPost = false, UserCanView = true})); var result = await service.PostNewTopic(user, new NewPost {ItemID = forum.ForumID}, "", "", x => "", x => ""); Assert.False(result.IsSuccessful); } [Fact] public async Task UserWithoutViewPermissionReturnsFalseIsSuccess() { var service = GetService(); var forum = new Forum { ForumID = 1 }; _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); var user = GetUser(); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { DenialReason = Resources.ForumNoView, UserCanModerate = false, UserCanPost = false, UserCanView = false })); var result = await service.PostNewTopic(user, new NewPost { ItemID = forum.ForumID }, "", "", x => "", x => ""); Assert.False(result.IsSuccessful); } [Fact] public async Task NoForumMatchThrows() { var service = GetService(); _forumRepo.Get(Arg.Any()).Returns((Forum) null); await Assert.ThrowsAsync(async () => await service.PostNewTopic(GetUser(), new NewPost {ItemID = 1}, "", "", x => "", x => "")); } [Fact] public async Task CallsPostRepoCreateWithPlainText() { var forum = new Forum { ForumID = 1 }; var user = GetUser(); const string ip = "127.0.0.1"; const string title = "mah title"; const string text = "mah text"; var newPost = new NewPost { Title = title, FullText = text, ItemID = 1, IsPlainText = true }; var topicService = GetService(); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(forum.ForumID).Returns(Task.FromResult(new List())); _topicRepo.GetUrlNamesThatStartWith("parsed-title").Returns(Task.FromResult(new List())); _textParser.ClientHtmlToHtml("mah text").Returns("html text"); _textParser.ForumCodeToHtml("mah text").Returns("bb text"); _textParser.Censor("mah title").Returns("parsed title"); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanModerate = false, UserCanPost = true, UserCanView = true })); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile())); await topicService.PostNewTopic(user, newPost, ip, default, x => "", x => ""); await _postRepo.Received().Create(0, 0, ip, true, false, user.UserID, user.Name, "parsed title", "bb text", Arg.Any(), false, user.Name, null, false, 0); await _topicRepo.Received().Create(forum.ForumID, "parsed title", 0, 0, user.UserID, user.Name, user.UserID, user.Name, Arg.Any(), false, false, false, "parsed-title"); } [Fact] public async Task CallsPostRepoCreateWithHtmlText() { var forum = new Forum { ForumID = 1 }; var user = GetUser(); const string ip = "127.0.0.1"; const string title = "mah title"; const string text = "mah text"; var newPost = new NewPost { Title = title, FullText = text, ItemID = 1, IsPlainText = false }; var topicService = GetService(); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(forum.ForumID).Returns(Task.FromResult(new List())); _topicRepo.GetUrlNamesThatStartWith("parsed-title").Returns(Task.FromResult(new List())); _textParser.ClientHtmlToHtml("mah text").Returns("html text"); _textParser.ForumCodeToHtml("mah text").Returns("bb text"); _textParser.Censor("mah title").Returns("parsed title"); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanModerate = false, UserCanPost = true, UserCanView = true })); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile())); await topicService.PostNewTopic(user, newPost, ip, default, x => "", x => ""); await _postRepo.Received().Create(0, 0, ip, true, false, user.UserID, user.Name, "parsed title", "html text", Arg.Any(), false, user.Name, null, false, 0); await _topicRepo.Received().Create(forum.ForumID, "parsed title", 0, 0, user.UserID, user.Name, user.UserID, user.Name, Arg.Any(), false, false, false, "parsed-title"); } [Fact] public async Task CallsPostRepoWithTopicID() { var forum = new Forum { ForumID = 1 }; var user = GetUser(); const string ip = "127.0.0.1"; const string title = "mah title"; const string text = "mah text"; var newPost = new NewPost { Title = title, FullText = text, ItemID = 1, IsPlainText = false }; var topicService = GetService(); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(forum.ForumID).Returns(Task.FromResult(new List())); _topicRepo.GetUrlNamesThatStartWith("parsed-title").Returns(Task.FromResult(new List())); _textParser.ClientHtmlToHtml("mah text").Returns("html text"); _textParser.ForumCodeToHtml("mah text").Returns("bb text"); _textParser.Censor("mah title").Returns("parsed title"); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanModerate = false, UserCanPost = true, UserCanView = true })); _topicRepo.Create(forum.ForumID, "parsed title", 0, 0, user.UserID, user.Name, user.UserID, user.Name, Arg.Any(), false, false, false, "parsed-title").Returns(Task.FromResult(543)); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile())); await topicService.PostNewTopic(user, newPost, ip, default, x => "", x => ""); await _postRepo.Received().Create(543, 0, ip, true, false, user.UserID, user.Name, "parsed title", "html text", Arg.Any(), false, user.Name, null, false, 0); } [Fact] public async Task CallsSubscribeServiceWithUserAndTopicIfEnabled() { var forum = new Forum { ForumID = 1 }; var user = GetUser(); const string ip = "127.0.0.1"; const string title = "mah title"; const string text = "mah text"; var newPost = new NewPost { Title = title, FullText = text, ItemID = 1, IsPlainText = false }; var profile = new Profile {UserID = user.UserID, IsAutoFollowOnReply = true}; var topicService = GetService(); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(forum.ForumID).Returns(Task.FromResult(new List())); _topicRepo.GetUrlNamesThatStartWith("parsed-title").Returns(Task.FromResult(new List())); _textParser.ClientHtmlToHtml("mah text").Returns("html text"); _textParser.ForumCodeToHtml("mah text").Returns("bb text"); _textParser.Censor("mah title").Returns("parsed title"); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanModerate = false, UserCanPost = true, UserCanView = true })); _topicRepo.Create(forum.ForumID, "parsed title", 0, 0, user.UserID, user.Name, user.UserID, user.Name, Arg.Any(), false, false, false, "parsed-title").Returns(Task.FromResult(543)); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(profile)); await topicService.PostNewTopic(user, newPost, ip, default, x => "", x => ""); await _subscribedTopicsService.Received().AddSubscribedTopic(user.UserID, 543); } [Fact] public async Task CallsPostImageServiceWithIDs() { var forum = new Forum { ForumID = 1 }; var user = GetUser(); const string ip = "127.0.0.1"; const string title = "mah title"; const string text = "mah text"; var postImageIDs = new string[] {Guid.NewGuid().ToString(), Guid.NewGuid().ToString()}; var newPost = new NewPost { Title = title, FullText = text, ItemID = 1, IsPlainText = false, PostImageIDs = postImageIDs}; var profile = new Profile { UserID = user.UserID, IsAutoFollowOnReply = true }; var topicService = GetService(); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(forum.ForumID).Returns(Task.FromResult(new List())); _topicRepo.GetUrlNamesThatStartWith("parsed-title").Returns(Task.FromResult(new List())); _textParser.ClientHtmlToHtml("mah text").Returns("html text"); _textParser.ForumCodeToHtml("mah text").Returns("bb text"); _textParser.Censor("mah title").Returns("parsed title"); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanModerate = false, UserCanPost = true, UserCanView = true })); _topicRepo.Create(forum.ForumID, "parsed title", 0, 0, user.UserID, user.Name, user.UserID, user.Name, Arg.Any(), false, false, false, "parsed-title").Returns(Task.FromResult(543)); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(profile)); await topicService.PostNewTopic(user, newPost, ip, default, x => "", x => ""); await _postImageService.Received().DeleteTempRecords(postImageIDs, newPost.FullText); } [Fact] public async Task DupeOfLastPostReturnsFalseIsSuccess() { var service = GetService(); var forum = new Forum { ForumID = 1 }; _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); var user = GetUser(); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanPost = true, UserCanView = true })); var lastPost = "last post text"; var lastPostID = 456; _profileRepo.GetLastPostID(user.UserID).Returns(lastPostID); _postRepo.Get(lastPostID).Returns(Task.FromResult(new Post {FullText = lastPost, PostTime = DateTime.MinValue})); _textParser.ClientHtmlToHtml(lastPost).Returns(lastPost); _settingsManager.Current.MinimumSecondsBetweenPosts.Returns(9); var result = await service.PostNewTopic(user, new NewPost { ItemID = forum.ForumID, FullText = lastPost }, "", "", x => "", x => ""); Assert.False(result.IsSuccessful); Assert.Equal(string.Format(Resources.PostWait, 9), result.Message); } [Fact] public async Task MinimumTimeBetweenPostsNotMetReturnsFalseIsSuccess() { var service = GetService(); var forum = new Forum { ForumID = 1 }; _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); var user = GetUser(); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanPost = true, UserCanView = true })); var lastPost = "last post text"; var lastPostID = 456; _profileRepo.GetLastPostID(user.UserID).Returns(lastPostID); _postRepo.Get(lastPostID).Returns(Task.FromResult(new Post { FullText = lastPost, PostTime = DateTime.UtcNow })); _textParser.ClientHtmlToHtml(lastPost).Returns(lastPost); _settingsManager.Current.MinimumSecondsBetweenPosts.Returns(9); var result = await service.PostNewTopic(user, new NewPost { ItemID = forum.ForumID, FullText = "oiheorihgeorihg" }, "", "", x => "", x => ""); Assert.False(result.IsSuccessful); Assert.Equal(string.Format(Resources.PostWait, 9), result.Message); } [Fact] public async Task CallsTopicRepoCreate() { var forum = new Forum { ForumID = 1 }; var user = GetUser(); const string ip = "127.0.0.1"; const string title = "mah title"; const string text = "mah text"; var newPost = new NewPost { Title = title, FullText = text, ItemID = 1 }; var topicService = GetService(); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(forum.ForumID).Returns(Task.FromResult(new List())); _topicRepo.GetUrlNamesThatStartWith("parsed-title").Returns(Task.FromResult(new List())); _textParser.ClientHtmlToHtml("mah text").Returns("parsed text"); _textParser.Censor("mah title").Returns("parsed title"); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanModerate = false, UserCanPost = true, UserCanView = true })); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile())); await topicService.PostNewTopic(user, newPost, ip, default, x => "", x => ""); await _topicRepo.Received().Create(forum.ForumID, "parsed title", 0, 0, user.UserID, user.Name, user.UserID, user.Name, Arg.Any(), false, false, false, "parsed-title"); } [Fact] public async Task TitleIsParsed() { var forum = new Forum { ForumID = 1 }; var user = GetUser(); const string ip = "127.0.0.1"; const string title = "mah title"; const string text = "mah text"; var newPost = new NewPost { Title = title, FullText = text, ItemID = 1 }; var topicService = GetService(); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(forum.ForumID).Returns(Task.FromResult(new List())); _topicRepo.GetUrlNamesThatStartWith("parsed-title").Returns(Task.FromResult(new List())); _textParser.ClientHtmlToHtml("mah text").Returns("parsed text"); _textParser.Censor("mah title").Returns("parsed title"); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanModerate = false, UserCanPost = true, UserCanView = true })); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile())); await topicService.PostNewTopic(user, newPost, ip, default, _ => "", _ => ""); await _topicRepo.Received().Create(forum.ForumID, "parsed title", 0, 0, user.UserID, user.Name, user.UserID, user.Name, Arg.Any(), false, false, false, "parsed-title"); } [Fact] public async Task CallsForumTopicPostIncrement() { await DoUpNewTopic(); await _forumRepo.Received().IncrementPostAndTopicCount(1); } [Fact] public async Task CallsForumUpdateLastUser() { var user = await DoUpNewTopic(); await _forumRepo.Received().UpdateLastTimeAndUser(1, Arg.Any(), user.Name); } [Fact] public async Task CallsProfileSetLastPost() { var user = await DoUpNewTopic(); await _profileRepo.Received().SetLastPostID(user.UserID, 69); } [Fact] public async Task PublishesNewTopicEvent() { var user = await DoUpNewTopic(); await _eventPublisher.Received().ProcessEvent(Arg.Any(), user, EventDefinitionService.StaticEventIDs.NewTopic, false); } [Fact] public async Task PublishesNewPostEvent() { var user = await DoUpNewTopic(); await _eventPublisher.Received().ProcessEvent(String.Empty, user, EventDefinitionService.StaticEventIDs.NewPost, true); } [Fact] public async Task CallsBroker() { await DoUpNewTopic(); _broker.Received().NotifyForumUpdate(Arg.Any()); _broker.Received().NotifyTopicUpdate(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task QueuesTopicForIndexing() { await DoUpNewTopic(); _tenantService.GetTenant().Returns(""); await _searchIndexQueueRepo.Received().Enqueue(Arg.Any()); } [Fact] public async Task DoesNotPublishToFeedIfForumHasViewRestrictions() { var forum = new Forum { ForumID = 1 }; var user = GetUser(); const string ip = "127.0.0.1"; const string title = "mah title"; const string text = "mah text"; var newPost = new NewPost { Title = title, FullText = text, ItemID = 1 }; var topicService = GetService(); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(forum.ForumID).Returns(Task.FromResult(new List { "Admin" })); _topicRepo.GetUrlNamesThatStartWith("parsed-title").Returns(Task.FromResult(new List())); _textParser.ClientHtmlToHtml("mah text").Returns("parsed text"); _textParser.Censor("mah title").Returns("parsed title"); _topicRepo.Create(forum.ForumID, "parsed title", 0, 0, user.UserID, user.Name, user.UserID, user.Name, Arg.Any(), false, false, false, "parsed-title").Returns(Task.FromResult(2)); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanModerate = false, UserCanPost = true, UserCanView = true })); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile())); await topicService.PostNewTopic(user, newPost, ip, default, x => "", x => ""); await _eventPublisher.Received().ProcessEvent(Arg.Any(), Arg.Any(), EventDefinitionService.StaticEventIDs.NewTopic, true); } [Fact] public async Task ReturnsTopic() { var forum = new Forum {ForumID = 1}; var user = GetUser(); const string ip = "127.0.0.1"; const string title = "mah title"; const string text = "mah text"; var newPost = new NewPost {Title = title, FullText = text, ItemID = 1}; var topicService = GetService(); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(forum.ForumID).Returns(Task.FromResult(new List())); _topicRepo.GetUrlNamesThatStartWith("parsed-title").Returns(Task.FromResult(new List())); _textParser.ClientHtmlToHtml("mah text").Returns("parsed text"); _textParser.Censor("mah title").Returns("parsed title"); _topicRepo.Create(forum.ForumID, "parsed title", 0, 0, user.UserID, user.Name, user.UserID, user.Name, Arg.Any(), false, false, false, "parsed-title").Returns(Task.FromResult(2)); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanModerate = false, UserCanPost = true, UserCanView = true })); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile())); var result = await topicService.PostNewTopic(user, newPost, ip, default, x => "", x => ""); Assert.Equal(2, result.Data.TopicID); Assert.Equal(forum.ForumID, result.Data.ForumID); Assert.Equal("parsed title", result.Data.Title); Assert.Equal(0, result.Data.ReplyCount); Assert.Equal(0, result.Data.ViewCount); Assert.Equal(user.UserID, result.Data.StartedByUserID); Assert.Equal(user.Name, result.Data.StartedByName); Assert.Equal(user.UserID, result.Data.LastPostUserID); Assert.Equal(user.Name, result.Data.LastPostName); Assert.False(result.Data.IsClosed); Assert.False(result.Data.IsDeleted); Assert.False(result.Data.IsPinned); Assert.Equal("parsed-title", result.Data.UrlName); } } public class PostReplyTests : PostMasterServiceTests { [Fact] public async Task NoUserReturnsFalseIsSuccessful() { var service = GetService(); var result = await service.PostReply(null, 0, "", false, new NewPost(), DateTime.MaxValue, (t) => "", "", x => "", x => ""); Assert.False(result.IsSuccessful); } [Fact] public async Task NoTopicReturnsFalseIsSuccessful() { var service = GetService(); _topicRepo.Get(Arg.Any()).Returns((Topic) null); var result = await service.PostReply(GetUser(), 0, "", false, new NewPost(), DateTime.MaxValue, (t) => "", "", x => "", x => ""); Assert.False(result.IsSuccessful); Assert.Equal(Resources.TopicNotExist, result.Message); } [Fact] public async Task NoForumThrows() { var service = GetService(); _topicRepo.Get(Arg.Any()).Returns(Task.FromResult(new Topic())); _forumRepo.Get(Arg.Any()).Returns((Forum) null); await Assert.ThrowsAsync(async () => await service.PostReply(GetUser(), 0, "", false, new NewPost(), DateTime.MaxValue, (t) => "", "", x => "", x => "")); } [Fact] public async Task NoViewPermissionReturnsFalseIsSuccessful() { var service = GetService(); var user = GetUser(); var forum = new Forum {ForumID = 1}; var topic = new Topic {ForumID = forum.ForumID}; var newPost = new NewPost {ItemID = topic.TopicID}; _topicRepo.Get(Arg.Any()).Returns(Task.FromResult(topic)); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext {UserCanView = false, UserCanPost = true})); var result = await service.PostReply(user, 0, "", false, newPost, DateTime.MaxValue, (t) => "", "", x => "", x => ""); Assert.False(result.IsSuccessful); Assert.Equal(Resources.ForumNoView, result.Message); } [Fact] public async Task NoPostPermissionReturnsFalseIsSuccessful() { var service = GetService(); var user = GetUser(); var forum = new Forum { ForumID = 1 }; var topic = new Topic { ForumID = forum.ForumID }; var newPost = new NewPost { ItemID = topic.TopicID }; _topicRepo.Get(Arg.Any()).Returns(Task.FromResult(topic)); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanView = true, UserCanPost = false })); var result = await service.PostReply(user, 0, "", false, newPost, DateTime.MaxValue, (t) => "", "", x => "", x => ""); Assert.False(result.IsSuccessful); Assert.Equal(Resources.ForumNoPost, result.Message); } [Fact] public async Task ClosedTopicReturnsFalseIsSuccessful() { var service = GetService(); _topicRepo.Get(Arg.Any()).Returns(Task.FromResult(new Topic{IsClosed = true})); var result = await service.PostReply(GetUser(), 0, "", false, new NewPost(), DateTime.MaxValue,(t) => "", "", x => "", x => ""); Assert.False(result.IsSuccessful); Assert.Equal(Resources.Closed, result.Message); } [Fact] public async Task UsesPlainTextParsed() { var topic = new Topic { TopicID = 1, Title = "" }; var user = GetUser(); var postTime = DateTime.UtcNow; var service = GetService(); var forum = new Forum { ForumID = topic.ForumID }; _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanPost = true, UserCanView = true })); _textParser.ForumCodeToHtml(Arg.Any()).Returns("parsed text"); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(Arg.Any()).Returns(Task.FromResult(new List())); var newPost = new NewPost { FullText = "mah text", Title = "mah title", IncludeSignature = true, ItemID = topic.TopicID, IsPlainText = true }; _textParser.Censor(newPost.Title).Returns("parsed title"); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile())); await service.PostReply(user, 0, "127.0.0.1", false, newPost, postTime, (t) => "", "", x => "", x => ""); await _postRepo.Received().Create(topic.TopicID, 0, "127.0.0.1", false, true, user.UserID, user.Name, "parsed title", "parsed text", postTime, false, user.Name, null, false, 0); } [Fact] public async Task UsesRichTextParsed() { var topic = new Topic { TopicID = 1, Title = "" }; var user = GetUser(); var postTime = DateTime.UtcNow; var service = GetService(); var forum = new Forum { ForumID = topic.ForumID }; _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanPost = true, UserCanView = true })); _textParser.ClientHtmlToHtml(Arg.Any()).Returns("parsed text"); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(Arg.Any()).Returns(Task.FromResult(new List())); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile())); var newPost = new NewPost { FullText = "mah text", Title = "mah title", IncludeSignature = true, ItemID = topic.TopicID, IsPlainText = false }; _textParser.Censor(newPost.Title).Returns("parsed title"); await service.PostReply(user, 0, "127.0.0.1", false, newPost, postTime, (t) => "", "", x => "", x => ""); await _postRepo.Received().Create(topic.TopicID, 0, "127.0.0.1", false, true, user.UserID, user.Name, "parsed title", "parsed text", postTime, false, user.Name, null, false, 0); } [Fact] public async Task DupeOfLastPostFails() { var topic = new Topic { TopicID = 1, Title = "" }; var user = GetUser(); var postTime = DateTime.UtcNow; var service = GetService(); var forum = new Forum { ForumID = topic.ForumID }; _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanPost = true, UserCanView = true })); _textParser.ClientHtmlToHtml(Arg.Any()).Returns("parsed text"); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(Arg.Any()).Returns(Task.FromResult(new List())); _profileRepo.GetLastPostID(user.UserID).Returns(654); _postRepo.Get(654).Returns(Task.FromResult(new Post {FullText = "parsed text", PostTime = DateTime.MinValue})); _settingsManager.Current.MinimumSecondsBetweenPosts.Returns(9); var newPost = new NewPost { FullText = "mah text", Title = "mah title", IncludeSignature = true, ItemID = topic.TopicID, IsPlainText = false }; var result = await service.PostReply(user, 0, "127.0.0.1", false, newPost, postTime, (t) => "", "", x => "", x => ""); Assert.False(result.IsSuccessful); Assert.Equal(string.Format(Resources.PostWait, 9), result.Message); } [Fact] public async Task MinTimeSinceLastPostTooShortFails() { var topic = new Topic { TopicID = 1, Title = "" }; var user = GetUser(); var postTime = DateTime.UtcNow; var service = GetService(); var forum = new Forum { ForumID = topic.ForumID }; _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanPost = true, UserCanView = true })); _textParser.ClientHtmlToHtml(Arg.Any()).Returns("oihf text"); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(Arg.Any()).Returns(Task.FromResult(new List())); _profileRepo.GetLastPostID(user.UserID).Returns(654); _postRepo.Get(654).Returns(Task.FromResult(new Post { FullText = "parsed text", PostTime = DateTime.UtcNow })); _settingsManager.Current.MinimumSecondsBetweenPosts.Returns(9); var newPost = new NewPost { FullText = "mah text", Title = "mah title", IncludeSignature = true, ItemID = topic.TopicID, IsPlainText = false }; var result = await service.PostReply(user, 0, "127.0.0.1", false, newPost, postTime, (t) => "", "", x => "", x => ""); Assert.False(result.IsSuccessful); Assert.Equal(string.Format(Resources.PostWait, 9), result.Message); } [Fact] public async Task EmptyPostFails() { var topic = new Topic { TopicID = 1, Title = "" }; var user = GetUser(); var postTime = DateTime.UtcNow; var service = GetService(); var forum = new Forum { ForumID = topic.ForumID }; _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanPost = true, UserCanView = true })); _textParser.ClientHtmlToHtml(Arg.Any()).Returns(""); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(Arg.Any()).Returns(Task.FromResult(new List())); _profileRepo.GetLastPostID(user.UserID).Returns(654); _settingsManager.Current.MinimumSecondsBetweenPosts.Returns(9); var newPost = new NewPost { FullText = "mah text", Title = "mah title", IncludeSignature = true, ItemID = topic.TopicID, IsPlainText = false }; var result = await service.PostReply(user, 0, "127.0.0.1", false, newPost, postTime, (t) => "", "", x => "", x => ""); Assert.False(result.IsSuccessful); Assert.Equal(Resources.PostEmpty, result.Message); } [Fact] public async Task HitsRepo() { var topic = new Topic { TopicID = 1, Title = "" }; var user = GetUser(); var postTime = DateTime.UtcNow; var service = GetService(); var forum = new Forum { ForumID = topic.ForumID }; _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanPost = true, UserCanView = true })); _textParser.ClientHtmlToHtml(Arg.Any()).Returns("parsed text"); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(Arg.Any()).Returns(Task.FromResult(new List())); var newPost = new NewPost { FullText = "mah text", Title = "mah title", IncludeSignature = true, ItemID = topic.TopicID }; _textParser.Censor(newPost.Title).Returns("parsed title"); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile())); await service.PostReply(user, 0, "127.0.0.1", false, newPost, postTime, (_) => "", "", x => "", x => ""); await _postRepo.Received().Create(topic.TopicID, 0, "127.0.0.1", false, true, user.UserID, user.Name, "parsed title", "parsed text", postTime, false, user.Name, null, false, 0); } [Fact] public async Task HitsSubscribedService() { var topic = new Topic { TopicID = 1 }; var user = GetUser(); var postTime = DateTime.UtcNow; var service = GetService(); var forum = new Forum { ForumID = topic.ForumID }; var tenandID = "cb"; _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanPost = true, UserCanView = true })); _textParser.ClientHtmlToHtml(Arg.Any()).Returns("parsed text"); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(Arg.Any()).Returns(Task.FromResult(new List())); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile())); var newPost = new NewPost { FullText = "mah text", Title = "mah title", IncludeSignature = true, ItemID = topic.TopicID }; _tenantService.GetTenant().Returns(tenandID); await service.PostReply(user, 0, "127.0.0.1", false, newPost, postTime, (t) => "", "", x => "", x => ""); await _subscribedTopicsService.Received().NotifySubscribers(topic, user, tenandID); } [Fact] public async Task HitsSubscribeAddWhenProfileCallsForIt() { var topic = new Topic { TopicID = 1 }; var user = GetUser(); var postTime = DateTime.UtcNow; var service = GetService(); var forum = new Forum { ForumID = topic.ForumID }; var profile = new Profile {UserID = user.UserID, IsAutoFollowOnReply = true}; _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanPost = true, UserCanView = true })); _textParser.ClientHtmlToHtml(Arg.Any()).Returns("parsed text"); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(Arg.Any()).Returns(Task.FromResult(new List())); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(profile)); var newPost = new NewPost { FullText = "mah text", Title = "mah title", IncludeSignature = true, ItemID = topic.TopicID }; await service.PostReply(user, 0, "127.0.0.1", false, newPost, postTime, (t) => "", "", x => "", x => ""); await _subscribedTopicsService.Received().AddSubscribedTopic(user.UserID, topic.TopicID); } [Fact] public async Task IncrementsTopicReplyCount() { var topic = new Topic { TopicID = 1 }; var user = GetUser(); var postTime = DateTime.UtcNow; var service = GetService(); var forum = new Forum { ForumID = topic.ForumID }; _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanPost = true, UserCanView = true })); _textParser.ClientHtmlToHtml(Arg.Any()).Returns("parsed text"); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(Arg.Any()).Returns(Task.FromResult(new List())); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile())); var newPost = new NewPost { FullText = "mah text", Title = "mah title", IncludeSignature = true, ItemID = topic.TopicID }; await service.PostReply(user, 0, "127.0.0.1", false, newPost, postTime, (t) => "", "", x => "", x => ""); await _topicRepo.Received().IncrementReplyCount(1); } [Fact] public async Task IncrementsForumPostCount() { var topic = new Topic { TopicID = 1, ForumID = 2 }; var user = GetUser(); var postTime = DateTime.UtcNow; var service = GetService(); var forum = new Forum { ForumID = topic.ForumID }; _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanPost = true, UserCanView = true })); _textParser.ClientHtmlToHtml(Arg.Any()).Returns("parsed text"); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(Arg.Any()).Returns(Task.FromResult(new List())); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile())); var newPost = new NewPost { FullText = "mah text", Title = "mah title", IncludeSignature = true, ItemID = topic.TopicID }; await service.PostReply(user, 0, "127.0.0.1", false, newPost, postTime, (t) => "", "", x => "", x => ""); await _forumRepo.Received().IncrementPostCount(2); } [Fact] public async Task UpdatesTopicLastInfo() { var topic = new Topic { TopicID = 1, ForumID = 2 }; var user = GetUser(); var postTime = DateTime.UtcNow; var service = GetService(); var forum = new Forum { ForumID = topic.ForumID }; _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanPost = true, UserCanView = true })); _textParser.ClientHtmlToHtml(Arg.Any()).Returns("parsed text"); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(Arg.Any()).Returns(Task.FromResult(new List())); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile())); var newPost = new NewPost { FullText = "mah text", Title = "mah title", IncludeSignature = true, ItemID = topic.TopicID }; await service.PostReply(user, 0, "127.0.0.1", false, newPost, postTime, (t) => "", "", x => "", x => ""); await _topicRepo.Received().UpdateLastTimeAndUser(topic.TopicID, user.UserID, user.Name, postTime); } [Fact] public async Task UpdatesForumLastInfo() { var topic = new Topic { TopicID = 1, ForumID = 2 }; var user = GetUser(); var postTime = DateTime.UtcNow; var service = GetService(); var forum = new Forum { ForumID = topic.ForumID }; _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanPost = true, UserCanView = true })); _textParser.ClientHtmlToHtml(Arg.Any()).Returns("parsed text"); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(Arg.Any()).Returns(Task.FromResult(new List())); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile())); var newPost = new NewPost { FullText = "mah text", Title = "mah title", IncludeSignature = true, ItemID = topic.TopicID }; await service.PostReply(user, 0, "127.0.0.1", false, newPost, postTime, (t) => "", "", x => "", x => ""); await _forumRepo.Received().UpdateLastTimeAndUser(topic.ForumID, postTime, user.Name); } [Fact] public async Task PostQueuesMarksTopicForIndexing() { var topic = new Topic { TopicID = 1, ForumID = 2 }; var user = GetUser(); var postTime = DateTime.UtcNow; var service = GetService(); var forum = new Forum {ForumID = topic.ForumID}; _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext {UserCanPost = true, UserCanView = true})); _textParser.ClientHtmlToHtml(Arg.Any()).Returns("parsed text"); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(Arg.Any()).Returns(Task.FromResult(new List())); _tenantService.GetTenant().Returns(""); _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(topic)); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile())); var newPost = new NewPost { FullText = "mah text", Title = "mah title", IncludeSignature = true, ItemID = topic.TopicID }; await service.PostReply(user, 0, "127.0.0.1", false, newPost, postTime,(t) => "", "", x => "", x => ""); await _searchIndexQueueRepo.Received().Enqueue(Arg.Any()); } [Fact] public async Task NotifiesBroker() { var topic = new Topic { TopicID = 1, ForumID = 2 }; var user = GetUser(); var postTime = DateTime.UtcNow; var service = GetService(); _forumRepo.GetForumViewRoles(Arg.Any()).Returns(Task.FromResult(new List())); var newPost = new NewPost { FullText = "mah text", Title = "mah title", IncludeSignature = true, ItemID = topic.TopicID }; var forum = new Forum { ForumID = topic.ForumID }; _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanPost = true, UserCanView = true })); _textParser.ClientHtmlToHtml(Arg.Any()).Returns("parsed text"); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.Get(topic.ForumID).Returns(Task.FromResult(forum)); _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(topic)); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile())); await service.PostReply(user, 0, "127.0.0.1", false, newPost, postTime, (t) => "", "", x => "", x => ""); _broker.Received().NotifyForumUpdate(forum); _broker.Received().NotifyTopicUpdate(topic, forum, Arg.Any()); _broker.Received().NotifyNewPost(topic, Arg.Any()); } [Fact] public async Task SetsProfileLastPostID() { var topic = new Topic { TopicID = 1, ForumID = 2 }; var user = GetUser(); var postTime = DateTime.UtcNow; var service = GetService(); var forum = new Forum { ForumID = topic.ForumID }; _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanPost = true, UserCanView = true })); _textParser.ClientHtmlToHtml(Arg.Any()).Returns("parsed text"); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(Arg.Any()).Returns(Task.FromResult(new List())); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile())); var newPost = new NewPost { FullText = "mah text", Title = "mah title", IncludeSignature = true, ItemID = topic.TopicID }; var result = await service.PostReply(user, 0, "127.0.0.1", false, newPost, postTime, (t) => "", "", x => "", x => ""); await _profileRepo.Received().SetLastPostID(user.UserID, result.Data.PostID); } [Fact] public async Task CallsPostImageServiceWithIDs() { var topic = new Topic { TopicID = 1, ForumID = 2 }; var user = GetUser(); var postTime = DateTime.UtcNow; var service = GetService(); var forum = new Forum { ForumID = topic.ForumID }; var postImageIDs = new string[] {Guid.NewGuid().ToString(), Guid.NewGuid().ToString()}; _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanPost = true, UserCanView = true })); _textParser.ClientHtmlToHtml(Arg.Any()).Returns("parsed text"); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(Arg.Any()).Returns(Task.FromResult(new List())); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile())); var newPost = new NewPost { FullText = "mah text", Title = "mah title", IncludeSignature = true, ItemID = topic.TopicID, PostImageIDs = postImageIDs}; var result = await service.PostReply(user, 0, "127.0.0.1", false, newPost, postTime, (t) => "", "", x => "", x => ""); await _postImageService.Received().DeleteTempRecords(postImageIDs, newPost.FullText); } [Fact] public async Task PublishesEvent() { var topic = new Topic { TopicID = 1, ForumID = 2 }; var user = GetUser(); var postTime = DateTime.UtcNow; var service = GetService(); var forum = new Forum { ForumID = topic.ForumID }; _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanPost = true, UserCanView = true })); _textParser.ClientHtmlToHtml(Arg.Any()).Returns("parsed text"); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(Arg.Any()).Returns(Task.FromResult(new List())); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile())); var newPost = new NewPost { FullText = "mah text", Title = "mah title", IncludeSignature = true, ItemID = topic.TopicID }; await service.PostReply(user, 0, "127.0.0.1", false, newPost, postTime, (t) => "", "", x => "", x => ""); await _eventPublisher.Received().ProcessEvent(Arg.Any(), user, EventDefinitionService.StaticEventIDs.NewPost, false); } [Fact] public async Task DoesNotPublishEventOnViewRestrictedForum() { var topic = new Topic { TopicID = 1, ForumID = 2 }; var user = GetUser(); var postTime = DateTime.UtcNow; var service = GetService(); var forum = new Forum {ForumID = topic.ForumID}; _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanPost = true, UserCanView = true })); _textParser.ClientHtmlToHtml(Arg.Any()).Returns("parsed text"); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _forumRepo.GetForumViewRoles(Arg.Any()).Returns(Task.FromResult(new List { "Admin" })); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile())); var newPost = new NewPost { FullText = "mah text", Title = "mah title", IncludeSignature = true, ItemID = topic.TopicID }; await service.PostReply(user, 0, "127.0.0.1", false, newPost, postTime, (t) => "", "", x => "", x => ""); await _eventPublisher.Received().ProcessEvent(Arg.Any(), user, EventDefinitionService.StaticEventIDs.NewPost, true); } [Fact] public async Task ReturnsHydratedObject() { var topic = new Topic { TopicID = 1 }; var user = GetUser(); var postTime = DateTime.UtcNow; var service = GetService(); _forumRepo.GetForumViewRoles(Arg.Any()).Returns(Task.FromResult(new List())); _postRepo.Create(topic.TopicID, 0, "127.0.0.1", false, true, user.UserID, user.Name, "parsed title", "parsed text", Arg.Any(), false, Arg.Any(), null, false, 0).Returns(Task.FromResult(123)); var forum = new Forum { ForumID = topic.ForumID }; _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumPermissionService.GetPermissionContext(forum, user).Returns(Task.FromResult(new ForumPermissionContext { UserCanPost = true, UserCanView = true })); _textParser.ClientHtmlToHtml(Arg.Any()).Returns("parsed text"); _forumRepo.Get(forum.ForumID).Returns(Task.FromResult(forum)); _textParser.Censor("mah title").Returns("parsed title"); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile())); var newPost = new NewPost { FullText = "mah text", Title = "mah title", IncludeSignature = true, ItemID = topic.TopicID }; var result = await service.PostReply(user, 0, "127.0.0.1", false, newPost, postTime, (t) => "", "", x => "", x => ""); Assert.Equal(topic.TopicID, result.Data.TopicID); Assert.Equal("parsed text", result.Data.FullText); Assert.Equal("127.0.0.1", result.Data.IP); Assert.False(result.Data.IsDeleted); Assert.False(result.Data.IsEdited); Assert.False(result.Data.IsFirstInTopic); Assert.Equal(user.Name, result.Data.LastEditName); Assert.Null(result.Data.LastEditTime); Assert.Equal(user.Name, result.Data.Name); Assert.Equal(0, result.Data.ParentPostID); Assert.Equal(123, result.Data.PostID); Assert.Equal(postTime, result.Data.PostTime); Assert.True(result.Data.ShowSig); Assert.Equal("parsed title", result.Data.Title); Assert.Equal(user.UserID, result.Data.UserID); } } public class EditPostTests : PostMasterServiceTests { private new User GetUser() { return new User {UserID = 123, Roles = new List()}; } [Fact] public async Task FailsBecauseNoUserMatch() { var service = GetService(); _postRepo.Get(456).Returns(Task.FromResult(new Post {UserID = 789})); var result = await service.EditPost(456, new PostEdit(), new User {UserID = 111, Roles = new List()}, x => ""); Assert.False(result.IsSuccessful); Assert.Equal(Resources.Forbidden, result.Message); } [Fact] public async Task CensorsTitle() { var service = GetService(); _postRepo.Get(456).Returns(Task.FromResult(new Post { PostID = 456, UserID = 123 })); await service.EditPost(456, new PostEdit { Title = "blah" }, GetUser(), x => ""); _textParser.Received(1).Censor("blah"); } [Fact] public async Task NoTitleUpdateWhenNotFirstPostInTopic() { var service = GetService(); _postRepo.Get(456).Returns(Task.FromResult(new Post { PostID = 456, UserID = 123, IsFirstInTopic = false, Title = "blah" })); _textParser.Censor("blah").Returns("changed"); await service.EditPost(456, new PostEdit { Title = "blah" }, GetUser(), x => ""); await _topicRepo.DidNotReceive().UpdateTitleAndForum(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task NoTitleUpdateWhenTitleHasNotChanged() { var service = GetService(); _postRepo.Get(456).Returns(Task.FromResult(new Post { PostID = 456, UserID = 123, IsFirstInTopic = true, Title = "blah" })); _textParser.Censor("blah").Returns("blah"); await service.EditPost(456, new PostEdit { Title = "blah" }, GetUser(), x => ""); await _topicRepo.DidNotReceive().UpdateTitleAndForum(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task NoEditWhenTitleIsEmpty() { var service = GetService(); _postRepo.Get(456).Returns(Task.FromResult(new Post { PostID = 456, UserID = 123, IsFirstInTopic = true, Title = "blah" })); _textParser.Censor("blah").Returns(""); var result = await service.EditPost(456, new PostEdit { Title = "blah" }, GetUser(), x => ""); await _topicRepo.DidNotReceive().UpdateTitleAndForum(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); Assert.False(result.IsSuccessful); } [Fact] public async Task TitleUpdateWhenFirstPostInTopicAndTitleChanged() { var service = GetService(); _postRepo.Get(456).Returns(Task.FromResult(new Post { PostID = 456, TopicID = 222, UserID = 123, IsFirstInTopic = true, Title = "blah" })); _topicRepo.Get(222).Returns(Task.FromResult(new Topic {TopicID = 222, ForumID = 111})); _forumRepo.Get(111).Returns(Task.FromResult(new Forum {ForumID = 111})); _textParser.Censor("blah").Returns("changed"); _topicRepo.GetUrlNamesThatStartWith("changed").Returns(Task.FromResult(new List())); await service.EditPost(456, new PostEdit { Title = "blah" }, GetUser(), x => ""); await _topicRepo.Received().UpdateTitleAndForum(222, 111, "changed", "changed"); } [Fact] public async Task PlainTextParsed() { var service = GetService(); _postRepo.Get(456).Returns(Task.FromResult(new Post { PostID = 456, UserID = 123 })); await service.EditPost(456, new PostEdit { FullText = "blah", IsPlainText = true }, GetUser(), x => ""); _textParser.Received(1).ForumCodeToHtml("blah"); } [Fact] public async Task RichTextParsed() { var service = GetService(); _postRepo.Get(456).Returns(Task.FromResult(new Post { PostID = 456, UserID = 123 })); await service.EditPost(456, new PostEdit { FullText = "blah", IsPlainText = false }, GetUser(), x => ""); _textParser.Received(1).ClientHtmlToHtml("blah"); } [Fact] public async Task SavesMappedValues() { var service = GetService(); var post = new Post { PostID = 67, IsFirstInTopic = true }; _postRepo.Get(456).Returns(Task.FromResult(new Post { PostID = 456, UserID = 123, IsFirstInTopic = true })); await _postRepo.Update(Arg.Do(p => post = p)); _topicRepo.Get(post.TopicID).Returns(Task.FromResult(new Topic {ForumID = 333})); _forumRepo.Get(333).Returns(Task.FromResult(new Forum {ForumID = 333})); _topicRepo.GetUrlNamesThatStartWith(Arg.Any()).Returns(Task.FromResult(new List())); _textParser.ClientHtmlToHtml("blah").Returns("new"); _textParser.Censor("unparsed title").Returns("new title"); var result = await service.EditPost(456, new PostEdit { FullText = "blah", Title = "unparsed title", IsPlainText = false, ShowSig = true, IsFirstInTopic = true }, new User { UserID = 123, Name = "dude", Roles = new List()}, x => ""); Assert.True(result.IsSuccessful); Assert.NotEqual(post.LastEditTime, new DateTime(2009, 1, 1)); Assert.Equal(456, post.PostID); Assert.Equal("new", post.FullText); Assert.Equal("new title", post.Title); Assert.True(post.ShowSig); Assert.True(post.IsEdited); Assert.Equal("dude", post.LastEditName); } [Fact] public async Task ModeratorLogged() { var service = GetService(); var user = new User { UserID = 123, Name = "dude", Roles = new List()}; _textParser.ClientHtmlToHtml("blah").Returns("new"); _textParser.Censor("unparsed title").Returns("new title"); _postRepo.Get(456).Returns(Task.FromResult(new Post{PostID = 456, UserID = user.UserID, FullText = "old text"})); var result = await service.EditPost(456, new PostEdit { FullText = "blah", Title = "unparsed title", IsPlainText = false, ShowSig = true, Comment = "mah comment" }, user, x => ""); Assert.True(result.IsSuccessful); await _moderationLogService.Received(1).LogPost(user, ModerationType.PostEdit, Arg.Any(), "mah comment", "old text"); } [Fact] public async Task QueuesTopicForIndexing() { var service = GetService(); var user = new User { UserID = 123, Name = "dude", Roles = new List() }; var post = new Post { PostID = 456, ShowSig = false, FullText = "old text", TopicID = 999, UserID = user.UserID }; _postRepo.Get(456).Returns(Task.FromResult(post)); _tenantService.GetTenant().Returns(""); var result = await service.EditPost(456, new PostEdit { FullText = "blah", Title = "unparsed title", IsPlainText = false, ShowSig = true, Comment = "mah comment" }, user, x => ""); Assert.True(result.IsSuccessful); await _searchIndexQueueRepo.Received().Enqueue(Arg.Any()); } [Fact] public async Task ModeratorCanEdit() { var service = GetService(); var post = new Post { PostID = 67 }; _postRepo.Get(456).Returns(Task.FromResult(new Post { PostID = 456, UserID = 123 })); await _postRepo.Update(Arg.Do(x => post = x)); _textParser.ClientHtmlToHtml("blah").Returns("new"); _textParser.Censor("unparsed title").Returns("new title"); var result = await service.EditPost(456, new PostEdit { FullText = "blah", Title = "unparsed title", IsPlainText = false, ShowSig = true }, new User { UserID = 123, Name = "dude", Roles = new List(new []{PermanentRoles.Moderator})}, x => ""); Assert.True(result.IsSuccessful); await _postRepo.Received().Update(Arg.Any()); } } } ================================================ FILE: src/PopForums.Test/Services/PostServiceTests.cs ================================================ namespace PopForums.Test.Services; public class PostServiceTests { private IPostRepository _postRepo; private IProfileRepository _profileRepo; private ISettingsManager _settingsManager; private Settings _settings; private ITopicService _topicService; private ITextParsingService _textParsingService; private IModerationLogService _modLogService; private IForumService _forumService; private IEventPublisher _eventPub; private IUserService _userService; private ISearchIndexQueueRepository _searchIndexQueue; private ITenantService _tenantService; private INotificationAdapter _notificationAdapter; private PostService GetService() { _postRepo = Substitute.For(); _profileRepo = Substitute.For(); _settingsManager = Substitute.For(); _settings = Substitute.For(); _topicService = Substitute.For(); _textParsingService = Substitute.For(); _modLogService = Substitute.For(); _forumService = Substitute.For(); _eventPub = Substitute.For(); _userService = Substitute.For(); _searchIndexQueue = Substitute.For(); _tenantService = Substitute.For(); _notificationAdapter = Substitute.For(); _settingsManager.Current.Returns(_settings); return new PostService(_postRepo, _profileRepo, _settingsManager, _topicService, _textParsingService, _modLogService, _forumService, _eventPub, _userService, _searchIndexQueue, _tenantService, _notificationAdapter); } [Fact] public async Task GetPostsPageSizeAndStartRowCalcdCorrectly() { var topic = new Topic { TopicID = 1, ReplyCount = 20}; var postService = GetService(); _settings.PostsPerPage.Returns(2); var (_, pagerContext) = await postService.GetPosts(topic, false, 4); await _postRepo.Received().Get(1, false, 7, 2); await _postRepo.DidNotReceive().GetReplyCount(Arg.Any(), Arg.Any()); Assert.Equal(11, pagerContext.PageCount); Assert.Equal(2, pagerContext.PageSize); } [Fact] public async Task GetPostsReplyCountCalledOnIncludeDeleted() { var topic = new Topic { TopicID = 1, ReplyCount = 20 }; var postService = GetService(); _settings.PostsPerPage.Returns(2); _postRepo.GetReplyCount(topic.TopicID, true).Returns(Task.FromResult(21)); var (_, pagerContext) = await postService.GetPosts(topic, true, 4); await _postRepo.Received().GetReplyCount(topic.TopicID, true); Assert.Equal(11, pagerContext.PageCount); } [Fact] public async Task GetPostsPagerContextConstructed() { var topic = new Topic { TopicID = 1, ReplyCount = 20 }; var postService = GetService(); _settings.PostsPerPage.Returns(3); var (_, pagerContext) = await postService.GetPosts(topic, false, 4); Assert.Equal(7, pagerContext.PageCount); Assert.Equal(4, pagerContext.PageIndex); } [Fact] public async Task GetPostsHitsRepo() { var topic = new Topic { TopicID = 1, ReplyCount = 20 }; var posts = new List(); var postService = GetService(); _settings.PostsPerPage.Returns(3); _postRepo.Get(1, false, Arg.Any(), Arg.Any()).Returns(Task.FromResult(posts)); var result = await postService.GetPosts(topic, false, 4); Assert.Same(posts, result.Item1); } [Fact] public async Task GetCallsRepoAndReturns() { var postService = GetService(); var postID = 123; var post = new Post {PostID = postID}; _postRepo.Get(postID).Returns(Task.FromResult(post)); var postResult = await postService.Get(postID); await _postRepo.Received().Get(postID); Assert.Same(postResult, post); } [Fact] public async Task GetPostCountCallsRepo() { var postService = GetService(); _postRepo.GetPostCount(123).Returns(Task.FromResult(456)); var user = new User { UserID = 123 }; var result = await postService.GetPostCount(user); await _postRepo.Received(1).GetPostCount(123); Assert.Equal(456, result); } [Fact] public async Task GetPostForEditPlainText() { var service = GetService(); var post = new Post { PostID = 123, Title = "mah title", FullText = "not", ShowSig = true, IsFirstInTopic = true }; var user = new User { UserID = 456 }; _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile {IsPlainText = true})); _textParsingService.HtmlToForumCode("not").Returns("new text"); var postEdit = await service.GetPostForEdit(post, user); Assert.Equal("mah title", postEdit.Title); Assert.Equal("new text", postEdit.FullText); Assert.True(postEdit.IsFirstInTopic); Assert.True(postEdit.ShowSig); Assert.True(postEdit.IsPlainText); _textParsingService.Received(1).HtmlToForumCode("not"); } [Fact] public async Task GetPostForEditNotPlainText() { var service = GetService(); var post = new Post { PostID = 123, Title = "mah title", FullText = "not", ShowSig = true, IsFirstInTopic = true }; var user = new User { UserID = 456 }; _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(new Profile { IsPlainText = false })); _textParsingService.HtmlToClientHtml("not").Returns("new text"); var postEdit = await service.GetPostForEdit(post, user); Assert.Equal("mah title", postEdit.Title); Assert.Equal("new text", postEdit.FullText); Assert.True(postEdit.IsFirstInTopic); Assert.True(postEdit.ShowSig); Assert.False(postEdit.IsPlainText); _textParsingService.Received(1).HtmlToClientHtml("not"); } [Fact] public async Task DeleteThrowsForNonAuthorAndNonMod() { var service = GetService(); var user = new User { UserID = 123 }; var post = new Post { PostID = 67 }; await Assert.ThrowsAsync(async () => await service.Delete(post, user)); } [Fact] public async Task DeleteCallDeleteTopicIfFirstInTopic() { var forum = new Forum { ForumID = 5 }; var topic = new Topic { TopicID = 4, ForumID = forum.ForumID }; var service = GetService(); var user = new User { UserID = 123 }; var post = new Post { PostID = 67, UserID = user.UserID, IsFirstInTopic = true, TopicID = topic.TopicID }; _topicService.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumService.Get(forum.ForumID).Returns(Task.FromResult(forum)); await service.Delete(post, user); await _topicService.Received(1).DeleteTopic(topic, user); } [Fact] public async Task DeleteCallLogs() { var forum = new Forum { ForumID = 5 }; var topic = new Topic { TopicID = 4, ForumID = forum.ForumID }; var service = GetService(); var user = new User { UserID = 123 }; var post = new Post { PostID = 67, UserID = user.UserID, IsFirstInTopic = false, TopicID = topic.TopicID }; _topicService.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumService.Get(forum.ForumID).Returns(Task.FromResult(forum)); await service.Delete(post, user); await _modLogService.Received(1).LogPost(user, ModerationType.PostDelete, post, String.Empty, String.Empty); } [Fact] public async Task DeleteSetsEditFields() { var forum = new Forum { ForumID = 5 }; var topic = new Topic { TopicID = 4, ForumID = forum.ForumID }; var service = GetService(); var user = new User { UserID = 123 }; var post = new Post { PostID = 67, UserID = user.UserID, IsFirstInTopic = false, TopicID = topic.TopicID, IsEdited = false }; _topicService.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumService.Get(forum.ForumID).Returns(Task.FromResult(forum)); var editedPost = new Post(); await _postRepo.Update(Arg.Do(x => editedPost = x)); await service.Delete(post, user); Assert.True(editedPost.IsEdited); Assert.Equal(user.Name, editedPost.LastEditName); Assert.True(editedPost.LastEditTime.HasValue); } [Fact] public async Task DeleteCallSetsIsDeletedAndUpdates() { var forum = new Forum { ForumID = 5 }; var topic = new Topic { TopicID = 4, ForumID = forum.ForumID }; var service = GetService(); var user = new User { UserID = 123 }; var post = new Post { PostID = 67, UserID = user.UserID, IsFirstInTopic = false, TopicID = topic.TopicID, IsDeleted = false }; _topicService.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumService.Get(forum.ForumID).Returns(Task.FromResult(forum)); var persistedPost = new Post(); await _postRepo.Update(Arg.Do(x => persistedPost = x)); await service.Delete(post, user); Assert.Equal(post.PostID, persistedPost.PostID); Assert.True(persistedPost.IsDeleted); } [Fact] public async Task DeleteCallFiresRecalcs() { var forum = new Forum { ForumID = 5 }; var topic = new Topic { TopicID = 4, ForumID = forum.ForumID }; var service = GetService(); var user = new User { UserID = 123 }; var post = new Post { PostID = 67, UserID = user.UserID, IsFirstInTopic = false, TopicID = topic.TopicID }; _topicService.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumService.Get(forum.ForumID).Returns(Task.FromResult(forum)); var payload = new SearchIndexPayload(); await _searchIndexQueue.Enqueue(Arg.Do(x => payload = x)); await service.Delete(post, user); await _topicService.Received(1).RecalculateReplyCount(topic); await _topicService.Received().UpdateLast(topic); _forumService.Received(1).UpdateCounts(forum); await _forumService.Received(1).UpdateLast(forum); await _searchIndexQueue.Received().Enqueue(payload); Assert.Equal(topic.TopicID, payload.TopicID); } [Fact] public async Task UndeleteThrowsForNonMod() { var service = GetService(); var user = new User { UserID = 123 }; var post = new Post { PostID = 67, UserID = user.UserID }; await Assert.ThrowsAsync(async () => await service.Undelete(post, user)); } [Fact] public async Task UndeleteCallLogs() { var forum = new Forum { ForumID = 5 }; var topic = new Topic { TopicID = 4, ForumID = forum.ForumID }; var service = GetService(); var user = new User { UserID = 123, Roles = new List { PermanentRoles.Moderator }}; var post = new Post { PostID = 67, TopicID = topic.TopicID }; _topicService.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumService.Get(forum.ForumID).Returns(Task.FromResult(forum)); await service.Undelete(post, user); await _modLogService.Received(1).LogPost(user, ModerationType.PostUndelete, post, String.Empty, String.Empty); } [Fact] public async Task UndeleteSetsEditFields() { var forum = new Forum { ForumID = 5 }; var topic = new Topic { TopicID = 4, ForumID = forum.ForumID }; var service = GetService(); var user = new User { UserID = 123, Roles = new List { PermanentRoles.Moderator } }; var post = new Post { PostID = 67, TopicID = topic.TopicID, IsEdited = false, UserID = user.UserID }; _topicService.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumService.Get(forum.ForumID).Returns(Task.FromResult(forum)); var editedPost = new Post(); await _postRepo.Update(Arg.Do(x => editedPost = x)); await service.Undelete(post, user); Assert.True(editedPost.IsEdited); Assert.Equal(user.Name, editedPost.LastEditName); Assert.True(editedPost.LastEditTime.HasValue); } [Fact] public async Task UndeleteCallSetsIsDeletedAndUpdates() { var forum = new Forum { ForumID = 5 }; var topic = new Topic { TopicID = 4, ForumID = forum.ForumID }; var service = GetService(); var user = new User { UserID = 123, Roles = new List { PermanentRoles.Moderator } }; var post = new Post { PostID = 67, TopicID = topic.TopicID, IsDeleted = true }; _topicService.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumService.Get(forum.ForumID).Returns(Task.FromResult(forum)); var persistedPost = new Post(); await _postRepo.Update(Arg.Do(x => persistedPost = x)); await service.Undelete(post, user); Assert.Equal(post.PostID, persistedPost.PostID); Assert.False(persistedPost.IsDeleted); } [Fact] public async Task UndeleteCallFiresRecalcs() { var forum = new Forum { ForumID = 5 }; var topic = new Topic { TopicID = 4, ForumID = forum.ForumID }; var service = GetService(); var user = new User { UserID = 123, Roles = new List { PermanentRoles.Moderator } }; var post = new Post { PostID = 67, TopicID = topic.TopicID }; _topicService.Get(topic.TopicID).Returns(Task.FromResult(topic)); _forumService.Get(forum.ForumID).Returns(Task.FromResult(forum)); var payload = new SearchIndexPayload(); await _searchIndexQueue.Enqueue(Arg.Do(x => payload = x)); await service.Undelete(post, user); await _topicService.Received(1).RecalculateReplyCount(topic); await _topicService.Received().UpdateLast(topic); _forumService.Received(1).UpdateCounts(forum); await _forumService.Received(1).UpdateLast(forum); await _searchIndexQueue.Received().Enqueue(payload); Assert.Equal(topic.TopicID, payload.TopicID); } [Fact] public async Task GetPostsToEndSkipsLoadedPosts() { var service = GetService(); var posts = new List {new Post { PostID = 1 }, new Post { PostID = 2 }, new Post { PostID = 3 }, new Post { PostID = 4 } }; _postRepo.Get(Arg.Any(), Arg.Any()).Returns(Task.FromResult(posts)); _settingsManager.Current.PostsPerPage.Returns(5); var result = await service.GetPosts(new Topic { TopicID = 123 }, 2, true); Assert.Equal(2, result.Item1.Count); Assert.Equal(3, result.Item1[0].PostID); Assert.Equal(4, result.Item1[1].PostID); } [Fact] public async Task GetPostsToEndCalcsCorrectPagerContext() { var service = GetService(); var posts = new List { new Post { PostID = 1 }, new Post { PostID = 2 }, new Post { PostID = 3 }, new Post { PostID = 4 }, new Post { PostID = 5 }, new Post { PostID = 6 }, new Post { PostID = 7 }, new Post { PostID = 8 } }; _postRepo.Get(Arg.Any(), Arg.Any()).Returns(Task.FromResult(posts)); _settingsManager.Current.PostsPerPage.Returns(3); var (_, pagerContext) = await service.GetPosts(new Topic { TopicID = 123 }, 2, true); Assert.Equal(3, pagerContext.PageCount); Assert.Equal(3, pagerContext.PageIndex); } [Fact] public async Task ToggleVoteCallsRepoWithPostIDAndUserID() { var service = GetService(); var post = new Post { PostID = 1 }; var user = new User { UserID = 2 }; _postRepo.GetVotes(post.PostID).Returns(Task.FromResult(new Dictionary())); await service.ToggleVoteReturnCountAndIsVoted(post, user, "abc", "def", "ghi"); await _postRepo.Received().VotePost(post.PostID, user.UserID); } [Fact] public async Task ToggleVoteCalcsAndSetsCount() { var service = GetService(); var post = new Post { PostID = 1 }; var user = new User { UserID = 2 }; const int votes = 32; _postRepo.GetVotes(post.PostID).Returns(Task.FromResult(new Dictionary())); _postRepo.CalculateVoteCount(post.PostID).Returns(Task.FromResult(votes)); await service.ToggleVoteReturnCountAndIsVoted(post, user, "abc", "def", "ghi"); await _postRepo.Received().SetVoteCount(post.PostID, votes); } [Fact] public async Task GetVoteCountCallsRepoAndReturns() { var service = GetService(); var post = new Post { PostID = 1 }; const int votes = 32; _postRepo.GetVoteCount(post.PostID).Returns(Task.FromResult(votes)); var result = await service.GetVoteCount(post); Assert.Equal(votes, result); } [Fact] public async Task GetVotersReturnsContainerWithPostID() { var service = GetService(); var post = new Post { PostID = 1 }; _postRepo.GetVotes(post.PostID).Returns(Task.FromResult(new Dictionary())); var result = await service.GetVoters(post); Assert.Equal(post.PostID, result.PostID); } [Fact] public async Task GetVotersReturnsContainerWithTotalVotes() { var service = GetService(); var post = new Post { PostID = 1 }; var voters = new Dictionary {{1, "Foo"}, {2, "Dude"}, {3, null}, {4, "Chica"}}; _postRepo.GetVotes(post.PostID).Returns(Task.FromResult(voters)); var result = await service.GetVoters(post); Assert.Equal(4, result.Votes); } [Fact] public async Task GetVotersFiltersNullNames() { var service = GetService(); var post = new Post { PostID = 1 }; var voters = new Dictionary { { 1, "Foo" }, { 2, "Dude" }, { 3, null }, { 4, "Chica" } }; _postRepo.GetVotes(post.PostID).Returns(Task.FromResult(voters)); var result = await service.GetVoters(post); Assert.Equal(3, result.Voters.Count); Assert.False(result.Voters.ContainsValue(null)); } [Fact] public async Task GetVotedIDsPassesUserID() { var service = GetService(); var user = new User { UserID = 123 }; await service.GetVotedPostIDs(user, new List()); await _postRepo.Received().GetVotedPostIDs(user.UserID, Arg.Any>()); } [Fact] public async Task GetVotedIDsPassesPostIDList() { var service = GetService(); var user = new User { UserID = 123 }; var list = new List {new Post { PostID = 4 }, new Post { PostID = 5 }, new Post { PostID = 8 } }; List returnedList = null; await _postRepo.GetVotedPostIDs(user.UserID, Arg.Do>(x => returnedList = x)); await service.GetVotedPostIDs(user, list); Assert.Equal(3, returnedList.Count); Assert.Equal(4, returnedList[0]); Assert.Equal(5, returnedList[1]); Assert.Equal(8, returnedList[2]); } [Fact] public async Task GetVotedIDsReturnsRepoObject() { var service = GetService(); var list = new List(); _postRepo.GetVotedPostIDs(Arg.Any(), Arg.Any>()).Returns(Task.FromResult(list)); var result = await service.GetVotedPostIDs(new User { UserID = 123 }, new List()); Assert.Same(list, result); } [Fact] public async Task GetVotedIDsReturnsEmptyListWithNullUser() { var service = GetService(); var result = await service.GetVotedPostIDs(null, new List()); Assert.Empty(result); } [Fact] public async Task ToggleVoteDoesntCallPublisherWhenUserFromPostDoesNotExist() { var service = GetService(); _userService.GetUser(Arg.Any()).Returns((User) null); _postRepo.GetVotes(Arg.Any()).Returns(Task.FromResult(new Dictionary())); await service.ToggleVoteReturnCountAndIsVoted(new Post { PostID = 123 }, new User { UserID = 456 }, "", "", ""); await _eventPub.DidNotReceive().ProcessEvent(Arg.Any(), Arg.Any(), Arg.Any(), false); } [Fact] public async Task ToggleVoteCallsEventPub() { var service = GetService(); var voteUpUser = new User { UserID = 777 }; _userService.GetUser(voteUpUser.UserID).Returns(Task.FromResult(voteUpUser)); _postRepo.GetVotes(Arg.Any()).Returns(Task.FromResult(new Dictionary())); await service.ToggleVoteReturnCountAndIsVoted(new Post { PostID = 123, UserID = voteUpUser.UserID }, new User { UserID = 456 }, "", "", ""); await _eventPub.Received().ProcessEvent(Arg.Any(), voteUpUser, EventDefinitionService.StaticEventIDs.PostVote, false); } [Fact] public async Task ToggleVoteCallsNotification() { var service = GetService(); var voteUpUser = new User { UserID = 777, Name = "Diana" }; var title = "the title"; _userService.GetUser(voteUpUser.UserID).Returns(Task.FromResult(voteUpUser)); _postRepo.GetVotes(Arg.Any()).Returns(Task.FromResult(new Dictionary())); await service.ToggleVoteReturnCountAndIsVoted(new Post { PostID = 123, UserID = voteUpUser.UserID }, new User { UserID = 456, Name = "Voter" }, "", "", title); await _notificationAdapter.Received().Vote("Voter", title, 123, voteUpUser.UserID); } [Fact] public async Task ToggleVoteDoesNotCallWhenUserIsPoster() { var service = GetService(); var voteUpUser = new User { UserID = 456 }; _userService.GetUser(voteUpUser.UserID).Returns(Task.FromResult(voteUpUser)); _postRepo.GetVotes(Arg.Any()).Returns(Task.FromResult(new Dictionary())); await service.ToggleVoteReturnCountAndIsVoted(new Post { PostID = 123, UserID = voteUpUser.UserID }, new User { UserID = 456 }, "", "", ""); await _eventPub.DidNotReceive().ProcessEvent(Arg.Any(), Arg.Any(), EventDefinitionService.StaticEventIDs.PostVote, false); } [Fact] public async Task ToggleVotePassesPubStringWithFormattedStuff() { var service = GetService(); var voteUpUser = new User { UserID = 777 }; const string userUrl = "http://abc"; const string topicUrl = "http://def"; const string title = "blah blah blah"; _userService.GetUser(voteUpUser.UserID).Returns(Task.FromResult(voteUpUser)); _postRepo.GetVotes(Arg.Any()).Returns(Task.FromResult(new Dictionary())); var message = String.Empty; await _eventPub.ProcessEvent(Arg.Do(x => message = x), Arg.Any(), EventDefinitionService.StaticEventIDs.PostVote, false); await service.ToggleVoteReturnCountAndIsVoted(new Post { PostID = 123, UserID = voteUpUser.UserID }, new User { UserID = 456 }, userUrl, topicUrl, title); Assert.Contains(userUrl, message); Assert.Contains(topicUrl, message); Assert.Contains(title, message); } [Fact] public void GenerateParsedTextPreviewCallsForumCodeForPlainText() { var service = GetService(); const string input = "ohgorigh"; const string output = "90eyuw"; _textParsingService.ForumCodeToHtml(input).Returns(output); var result = service.GenerateParsedTextPreview(input, true); Assert.Equal(output, result); _textParsingService.Received().ForumCodeToHtml(input); } [Fact] public void GenerateParsedTextPreviewCallsHtmlForRichText() { var service = GetService(); const string input = "ohgorigh"; const string output = "90eyuw"; _textParsingService.ClientHtmlToHtml(input).Returns(output); var result = service.GenerateParsedTextPreview(input, false); Assert.Equal(output, result); _textParsingService.Received().ClientHtmlToHtml(input); } } ================================================ FILE: src/PopForums.Test/Services/PrivateMessageServiceTests.cs ================================================ namespace PopForums.Test.Services; public class PrivateMessageServiceTests { private PrivateMessageService GetService() { _mockPMRepo = Substitute.For(); _mockSettings = Substitute.For(); _mockTextParse = Substitute.For(); _mockBroker = Substitute.For(); var service = new PrivateMessageService(_mockPMRepo, _mockSettings, _mockTextParse, _mockBroker); return service; } private IPrivateMessageRepository _mockPMRepo; private ISettingsManager _mockSettings; private ITextParsingService _mockTextParse; private IBroker _mockBroker; [Fact] public async Task CreateNullTextThrows() { var service = GetService(); await Assert.ThrowsAsync(() => service.Create(null, new User(), new List { new User() })); } [Fact] public async Task CreateEmptyTextThrows() { var service = GetService(); await Assert.ThrowsAsync(() => service.Create(String.Empty, new User(), new List { new User() })); } [Fact] public async Task CreateNullUserThrows() { var service = GetService(); await Assert.ThrowsAsync(() => service.Create("oho h", null, new List { new User() })); } [Fact] public async Task CreateNullToUsersThrows() { var service = GetService(); await Assert.ThrowsAsync(() => service.Create("oho h", new User(), null)); } [Fact] public async Task CreateZeroToUsersThrows() { var service = GetService(); await Assert.ThrowsAsync(() => service.Create("oho h", new User(), new List())); } [Fact] public async Task CreateSerializedUser() { var service = GetService(); var pm = await service.Create("oihefio", new User { UserID = 12, Name = "jeff"}, new List {new User { UserID = 45, Name = "diana"}}); Assert.Equal(45, pm.Users[0].GetProperty("userID").GetInt32()); Assert.Equal("diana", pm.Users[0].GetProperty("name").GetString()); } [Fact] public async Task CreateSerializedUsers() { var service = GetService(); var pm = await service.Create("oihefio", new User { UserID = 12, Name = "jeff" }, new List { new User { UserID = 45, Name = "diana" }, new User { UserID = 78, Name = "simon" } }); Assert.Equal(45, pm.Users[0].GetProperty("userID").GetInt32()); Assert.Equal("diana", pm.Users[0].GetProperty("name").GetString()); Assert.Equal(78, pm.Users[1].GetProperty("userID").GetInt32()); Assert.Equal("simon", pm.Users[1].GetProperty("name").GetString()); } [Fact] public async Task CreateCallsNotificationBroker() { var service = GetService(); _mockPMRepo.GetUnreadCount(45).Returns(Task.FromResult(3)); var pm = await service.Create("oihefio", new User { UserID = 12, Name = "jeff" }, new List { new User { UserID = 45, Name = "diana" } }); _mockBroker.Received().NotifyPMCount(45, 3); } [Fact] public async Task CreatePMPersistedIDReturned() { var service = GetService(); var persist = new PrivateMessage(); _mockPMRepo.CreatePrivateMessage(Arg.Do(x => persist = x)).Returns(Task.FromResult(69)); _mockTextParse.EscapeHtmlAndCensor("ohqefwwf").Returns("ohqefwwf"); var pm = await service.Create("oihefio", new User { UserID = 12, Name = "jeff" }, new List { new User { UserID = 45, Name = "diana" }, new User { UserID = 67, Name = "simon"} }); Assert.Equal(69, pm.PMID); } [Fact] public async Task CreateAllUsersPresisted() { var user = new User { UserID = 12 }; var to1 = new User { UserID = 45 }; var to2 = new User { UserID = 67 }; var service = GetService(); var users = new List(); var originalUser = new List(); _mockPMRepo.CreatePrivateMessage(Arg.Any()).Returns(Task.FromResult(69)); await _mockPMRepo.AddUsers(Arg.Any(), Arg.Do>(x => originalUser = x), Arg.Any(), false); await _mockPMRepo.AddUsers(Arg.Any(), Arg.Do>(x => users = x), Arg.Any(), false); await service.Create("oihefio", user, new List { to1, to2 }); Assert.Equal(2, users.Count); Assert.Equal(to1.UserID, users[0]); Assert.Equal(to2.UserID, users[1]); // TODO: figure out multiple setups with same parameters //Assert.Equal(user.UserID, originalUser[0]); } [Fact] public async Task CreatePostPersist() { var user = new User { UserID = 12, Name = "jeff" }; var to1 = new User { UserID = 45 }; var to2 = new User { UserID = 67 }; var service = GetService(); _mockPMRepo.CreatePrivateMessage(Arg.Any()).Returns(Task.FromResult(69)); var post = new PrivateMessagePost(); await _mockPMRepo.AddPost(Arg.Do(x => post = x)); _mockTextParse.ForumCodeToHtml("oihefio").Returns("oihefio"); await service.Create("oihefio", user, new List { to1, to2 }); Assert.Equal("oihefio", post.FullText); Assert.Equal("jeff", post.Name); Assert.Equal(69, post.PMID); Assert.Equal(user.UserID, post.UserID); } [Fact] public async Task ReplyNullPMThrows() { var service = GetService(); await Assert.ThrowsAsync(() => service.Reply(null, "ohifwefhi", new User())); } [Fact] public async Task ReplyNoIdPMThrows() { var service = GetService(); await Assert.ThrowsAsync(() => service.Reply(new PrivateMessage(), "ohifwefhi", new User())); } [Fact] public async Task ReplyNullTextThrows() { var service = GetService(); await Assert.ThrowsAsync(() => service.Reply(new PrivateMessage{ PMID = 2 }, null, new User())); } [Fact] public async Task ReplyEmptyTextThrows() { var service = GetService(); await Assert.ThrowsAsync(() => service.Reply(new PrivateMessage { PMID = 2 }, String.Empty, new User())); } [Fact] public async Task ReplyNullUserThrows() { var service = GetService(); await Assert.ThrowsAsync(() => service.Reply(new PrivateMessage { PMID = 2 }, "wfwgrg", null)); } [Fact] public async Task ReplyMapsAndPresistsPost() { var service = GetService(); var post = new PrivateMessagePost(); await _mockPMRepo.AddPost(Arg.Do(x => post = x)); var user = new User { UserID = 1, Name = "jeff"}; var pm = new PrivateMessage {PMID = 2}; var text = "mah message"; _mockTextParse.ForumCodeToHtml(text).Returns(text); _mockPMRepo.GetUsers(pm.PMID).Returns(Task.FromResult(new List {new PrivateMessageUser {UserID = user.UserID}})); _mockPMRepo.GetUnreadCount(user.UserID).Returns(Task.FromResult(42)); await service.Reply(pm, text, user); Assert.Equal(text, post.FullText); Assert.Equal(user.Name, post.Name); Assert.Equal(user.UserID, post.UserID); Assert.Equal(pm.PMID, post.PMID); _mockBroker.Received().NotifyPMCount(user.UserID, 42); } [Fact] public async Task ReplyThrowsIfUserIsntOnPM() { var service = GetService(); var user = new User { UserID = 1 }; _mockPMRepo.GetUsers(Arg.Any()).Returns(Task.FromResult(new List { new PrivateMessageUser { UserID = 456 } })); await Assert.ThrowsAsync(() => service.Reply(new PrivateMessage { PMID = 2 }, "wohfwo", user)); } [Fact] public async Task IsUserInPMTrue() { var service = GetService(); var user = new User { UserID = 1 }; var pm = new PrivateMessage { PMID = 2 }; _mockPMRepo.GetUsers(pm.PMID).Returns(Task.FromResult(new List { new PrivateMessageUser { UserID = user.UserID } })); Assert.True(await service.IsUserInPM(user.UserID, pm.PMID)); } [Fact] public async Task IsUserInPMFalse() { var service = GetService(); var user = new User { UserID = 1 }; var pm = new PrivateMessage { PMID = 2 }; _mockPMRepo.GetUsers(pm.PMID).Returns(Task.FromResult(new List { new PrivateMessageUser { UserID = 765 } })); Assert.False(await service.IsUserInPM(user.UserID, pm.PMID)); } } ================================================ FILE: src/PopForums.Test/Services/ProfileServiceTests.cs ================================================ namespace PopForums.Test.Services; public class ProfileServiceTests { private IProfileRepository _profileRepo; private ITextParsingService _textParsingService; private IPointLedgerRepository _pointLedger; private ProfileService GetService() { _profileRepo = Substitute.For(); _textParsingService = Substitute.For(); _pointLedger = Substitute.For(); return new ProfileService(_profileRepo, _textParsingService, _pointLedger); } [Fact] public async Task GetProfile() { var service = GetService(); var profile = new Profile { UserID = 123, Location = "Cleveland" }; var user = UserServiceTests.GetDummyUser("Jeff", "a@b.com"); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(profile)); var result = await service.GetProfile(user); Assert.Equal(profile, result); await _profileRepo.Received().GetProfile(user.UserID); } [Fact] public async Task GetProfileReturnsNullForNullUser() { var service = GetService(); var result = await service.GetProfile(null); Assert.Null(result); } [Fact] public async Task GetProfileForEditParsesSigRichText() { var service = GetService(); var profile = new Profile { UserID = 123, Location = "Cleveland", Signature = "blah", IsPlainText = false }; var user = UserServiceTests.GetDummyUser("Jeff", "a@b.com"); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(profile)); _textParsingService.HtmlToClientHtml("blah").Returns("parsed"); var result = await service.GetProfileForEdit(user); Assert.Equal("parsed", result.Signature); } [Fact] public async Task GetProfileForEditParsesSigPlainText() { var service = GetService(); var profile = new Profile { UserID = 123, Location = "Cleveland", Signature = "blah", IsPlainText = true }; var user = UserServiceTests.GetDummyUser("Jeff", "a@b.com"); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(profile)); _textParsingService.HtmlToForumCode("blah").Returns("parsed"); var result = await service.GetProfileForEdit(user); Assert.Equal("parsed", result.Signature); } [Fact] public async Task EditUserProfilePlainText() { var service = GetService(); var user = new User { UserID = 1 }; user.Roles = new List(); var returnedProfile = new Profile { UserID = 1, IsPlainText = true }; var profile = new Profile(); _profileRepo.GetProfile(1).Returns(Task.FromResult(returnedProfile)); await _profileRepo.Update(Arg.Do(x => profile = x)); _textParsingService.ForumCodeToHtml(Arg.Any()).Returns("parsed"); var userEdit = new UserEditProfile { Dob = new DateTime(2000, 1, 1), HideVanity = true, Instagram = "i", IsPlainText = true, IsSubscribed = true, Location = "l", Facebook = "fb", ShowDetails = true, Signature = "s", Web = "w", IsAutoFollowOnReply = true }; await service.EditUserProfile(user, userEdit); await _profileRepo.Received().Update(Arg.Any()); Assert.Equal(new DateTime(2000, 1, 1), profile.Dob); Assert.True(profile.HideVanity); Assert.Equal("i", profile.Instagram); Assert.True(profile.IsPlainText); Assert.True(profile.IsSubscribed); Assert.True(profile.IsAutoFollowOnReply); Assert.Equal("l", profile.Location); Assert.Equal("fb", profile.Facebook); Assert.True(profile.ShowDetails); Assert.Equal("parsed", profile.Signature); Assert.Equal("w", profile.Web); } [Fact] public async Task EditUserProfileRichText() { var service = GetService(); var user = new User { UserID = 1 }; user.Roles = new List(); var returnedProfile = new Profile { UserID = 1, IsPlainText = false }; var profile = new Profile(); _profileRepo.GetProfile(1).Returns(Task.FromResult(returnedProfile)); await _profileRepo.Update(Arg.Do(x => profile = x)); _textParsingService.ClientHtmlToHtml(Arg.Any()).Returns("parsed"); var userEdit = new UserEditProfile { Dob = new DateTime(2000, 1, 1), HideVanity = true, Instagram = "i", IsPlainText = true, IsSubscribed = true, Location = "l", Facebook = "fb", ShowDetails = true, Signature = "s", Web = "w" }; await service.EditUserProfile(user, userEdit); Assert.Equal("parsed", profile.Signature); } [Fact] public async Task GetProfileForEditParsesSigGuardForNull() { var service = GetService(); var profile = new Profile { UserID = 123, Location = "Cleveland", Signature = null }; var user = UserServiceTests.GetDummyUser("Jeff", "a@b.com"); _profileRepo.GetProfile(user.UserID).Returns(Task.FromResult(profile)); var result = await service.GetProfileForEdit(user); _textParsingService.DidNotReceive().ClientHtmlToForumCode(Arg.Any()); Assert.Equal(string.Empty, result.Signature); await _profileRepo.Received().GetProfile(user.UserID); } [Fact] public async Task CreateFromProfileObject() { var service = GetService(); var profile = new Profile { UserID = 123, Location = "Cleveland" }; await service.Create(profile); await _profileRepo.Received().Create(profile); } [Fact] public async Task CreateFromProfileThrowsWithoutUserID() { var service = GetService(); var profile = new Profile(); await Assert.ThrowsAsync(() => service.Create(profile)); await _profileRepo.DidNotReceive().Create(profile); } [Fact] public async Task Update() { var service = GetService(); var profile = new Profile { UserID = 123, Location = "Cleveland", Signature = ""}; _profileRepo.Update(profile).Returns(Task.FromResult(true)); await service.Update(profile); await _profileRepo.Received().Update(profile); } [Fact] public async Task UpdateTrimsSig() { var service = GetService(); var profile = new Profile { UserID = 123, Location = "Cleveland", Signature = " " }; var trimProfile = new Profile { Signature = "no"}; _profileRepo.Update(Arg.Do(x => trimProfile = x)).Returns(Task.FromResult(true)); await service.Update(profile); Assert.Equal("", trimProfile.Signature); } [Fact] public async Task UpdateThrowsWithNoProfile() { var service = GetService(); var profile = new Profile { UserID = 123, Location = "Cleveland", Signature = "" }; _profileRepo.Update(profile).Returns(Task.FromResult(false)); await Assert.ThrowsAsync(() => service.Update(profile)); await _profileRepo.Received().Update(profile); } [Fact] public async Task GetSigsOnlyTakesPostsWithShowSig() { var posts = new List { new Post { UserID = 1, ShowSig = false }, new Post { UserID = 2, ShowSig = true }, new Post { UserID = 3, ShowSig = false }, new Post { UserID = 4, ShowSig = true }, new Post { UserID = 5, ShowSig = true }, new Post { UserID = 6, ShowSig = false }, }; var service = GetService(); var ids = new List(); await _profileRepo.GetSignatures(Arg.Do>(x => ids = x)); await service.GetSignatures(posts); Assert.Equal(3, ids.Count); Assert.Equal(2, ids[0]); Assert.Equal(4, ids[1]); Assert.Equal(5, ids[2]); } [Fact] public async Task GetSigsDoesntSendDupeUserIDs() { var posts = new List { new Post { UserID = 1, ShowSig = false }, new Post { UserID = 2, ShowSig = true }, new Post { UserID = 2, ShowSig = false }, new Post { UserID = 2, ShowSig = true }, new Post { UserID = 3, ShowSig = true }, new Post { UserID = 3, ShowSig = true }, }; var service = GetService(); var ids = new List(); await _profileRepo.GetSignatures(Arg.Do>(x => ids = x)); await service.GetSignatures(posts); Assert.Equal(2, ids.Count); Assert.Equal(2, ids[0]); Assert.Equal(3, ids[1]); } [Fact] public async Task GetAvatarsDoesntSendDupeUserIDs() { var posts = new List { new Post { UserID = 1 }, new Post { UserID = 2 }, new Post { UserID = 2 }, new Post { UserID = 2 }, new Post { UserID = 3 }, new Post { UserID = 3 }, }; var service = GetService(); var ids = new List(); await _profileRepo.GetAvatars(Arg.Do>(x => ids = x)); await service.GetAvatars(posts); Assert.Equal(3, ids.Count); Assert.Equal(1, ids[0]); Assert.Equal(2, ids[1]); Assert.Equal(3, ids[2]); } [Fact] public async Task UpdatePointsUpdatesPoints() { var service = GetService(); var user = new User { UserID = 123 }; const int total = 87; _pointLedger.GetPointTotal(user.UserID).Returns(Task.FromResult(total)); await service.UpdatePointTotal(user); await _profileRepo.Received().UpdatePoints(user.UserID, total); } } ================================================ FILE: src/PopForums.Test/Services/QueuedEmailServiceTests.cs ================================================ namespace PopForums.Test.Services; public class QueuedEmailServiceTests { private IQueuedEmailMessageRepository _queuedEmailMessageRepo; private IEmailQueueRepository _emailQueueRepo; private ITenantService _tenantService; private QueuedEmailService GetService() { _queuedEmailMessageRepo = Substitute.For(); _emailQueueRepo = Substitute.For(); _tenantService = Substitute.For(); return new QueuedEmailService(_queuedEmailMessageRepo, _emailQueueRepo, _tenantService); } [Fact] public async Task CreateAndQueueEmailCallsRepoWithMessage() { var service = GetService(); var message = new QueuedEmailMessage(); _queuedEmailMessageRepo.CreateMessage(message).Returns(Task.FromResult(1)); _tenantService.GetTenant().Returns(""); await service.CreateAndQueueEmail(message); await _queuedEmailMessageRepo.Received().CreateMessage(message); } [Fact] public async Task CreateAndQueueEmailCallsEmailQueueWithCorrectPayload() { var service = GetService(); var messageID = 123; var message = new QueuedEmailMessage(); _queuedEmailMessageRepo.CreateMessage(message).Returns(Task.FromResult(messageID)); var tenantID = "t1"; _tenantService.GetTenant().Returns(tenantID); var payload = new EmailQueuePayload(); await _emailQueueRepo.Enqueue(Arg.Do(x => payload = x)); await service.CreateAndQueueEmail(message); Assert.Equal(messageID, payload.MessageID); Assert.Equal(EmailQueuePayloadType.FullMessage, payload.EmailQueuePayloadType); Assert.Equal(tenantID, payload.TenantID); } } ================================================ FILE: src/PopForums.Test/Services/SearchIndexWorkerTests.cs ================================================ using NSubstitute.ExceptionExtensions; using NSubstitute.ReturnsExtensions; namespace PopForums.Test.Services; public class SearchIndexWorkerTests { private IErrorLog _errorLog; private ISearchIndexSubsystem _searchIndexSubsystem; private ISearchService _searchService; private ISearchIndexWorker GetWorker() { _errorLog = Substitute.For(); _searchIndexSubsystem = Substitute.For(); _searchService = Substitute.For(); return new SearchIndexWorker(_errorLog, _searchIndexSubsystem, _searchService); } [Fact] public void DoNothingWhenNoPayload() { var worker = GetWorker(); _searchService.GetNextTopicForIndexing().ReturnsNull(); worker.Execute(); _searchIndexSubsystem.DidNotReceiveWithAnyArgs().DoIndex(Arg.Any(), Arg.Any(), Arg.Any()); _errorLog.DidNotReceiveWithAnyArgs().Log(Arg.Any(), Arg.Any()); } [Fact] public void CallSearchIndexSubsystemWhenPayload() { var worker = GetWorker(); var payload = new SearchIndexPayload { TopicID = 123, TenantID = "tenant", IsForRemoval = true }; _searchService.GetNextTopicForIndexing().Returns(payload); worker.Execute(); _searchIndexSubsystem.Received().DoIndex(123, "tenant", true); _errorLog.DidNotReceiveWithAnyArgs().Log(Arg.Any(), Arg.Any()); } [Fact] public void LogWhenGetNextTopicForIndexingThrows() { var worker = GetWorker(); _searchService.GetNextTopicForIndexing().ThrowsAsync(new Exception()); worker.Execute(); _searchIndexSubsystem.DidNotReceiveWithAnyArgs().DoIndex(Arg.Any(), Arg.Any(), Arg.Any()); _errorLog.Received().Log(Arg.Any(), ErrorSeverity.Error); } [Fact] public void LogWhenDoIndexThrows() { var worker = GetWorker(); var payload = new SearchIndexPayload { TopicID = 123, TenantID = "tenant", IsForRemoval = true }; _searchService.GetNextTopicForIndexing().Returns(payload); _searchIndexSubsystem.When(x => x.DoIndex(123, "tenant", true)).Throw(new Exception()); worker.Execute(); _searchIndexSubsystem.Received().DoIndex(123, "tenant", true); _errorLog.Received().Log(Arg.Any(), ErrorSeverity.Error); } } ================================================ FILE: src/PopForums.Test/Services/SearchServiceTests.cs ================================================ namespace PopForums.Test.Services; public class SearchServiceTests { private ISettingsManager _mockSettingsManager; private ISearchRepository _mockSearchRepo; private IForumService _mockForumService; private ISearchIndexQueueRepository _searchIndexQueueRepo; private IErrorLog _errorLog; private SearchService GetService() { _mockSearchRepo = Substitute.For(); _mockSettingsManager = Substitute.For(); _mockForumService = Substitute.For(); _searchIndexQueueRepo = Substitute.For(); _errorLog = Substitute.For(); return new SearchService(_mockSearchRepo, _mockSettingsManager, _mockForumService, _searchIndexQueueRepo, _errorLog); } [Fact] public async Task GetJunkWords() { var words = new List(); var service = GetService(); _mockSearchRepo.GetJunkWords().Returns(Task.FromResult(words)); var result = await service.GetJunkWords(); await _mockSearchRepo.Received().GetJunkWords(); Assert.Same(words, result); } [Fact] public async Task CreateWord() { var service = GetService(); await service.CreateJunkWord("blah"); await _mockSearchRepo.Received().CreateJunkWord("blah"); } [Fact] public async Task DeleteWord() { var service = GetService(); await service.DeleteJunkWord("blah"); await _mockSearchRepo.Received().DeleteJunkWord("blah"); } [Fact] public async Task GetTopicsReturnsValidResponseWithNoResultsWhenSearchTermIsNull() { var service = GetService(); _mockForumService.GetNonViewableForumIDs(null).Returns(Task.FromResult(new List())); _mockSettingsManager.Current.TopicsPerPage.Returns(20); var result = await service.GetTopics(null, SearchType.Rank, null, false, 1); Assert.Empty(result.Item1.Data); Assert.True(result.Item1.IsValid); } [Fact] public async Task GetTopicsReturnsValidResponseWithNoResultsWhenSearchTermIsEmpty() { var service = GetService(); _mockForumService.GetNonViewableForumIDs(null).Returns(Task.FromResult(new List())); _mockSettingsManager.Current.TopicsPerPage.Returns(20); var result = await service.GetTopics(String.Empty, SearchType.Rank, null, false, 1); Assert.Empty(result.Item1.Data); Assert.True(result.Item1.IsValid); } [Fact] public async Task GetTopicsIsCalledWithTheRightParameters() { var query = "test"; var user = new User(); var noViewIDs = new List {1}; var service = GetService(); _mockForumService.GetNonViewableForumIDs(user).Returns(Task.FromResult(noViewIDs)); _mockSettingsManager.Current.TopicsPerPage.Returns(20); _mockSearchRepo.SearchTopics(query, noViewIDs, SearchType.Rank, 1, 20).Returns((Tuple.Create(new Response>(new List()), 0))); await service.GetTopics(query, SearchType.Rank, user, false, 1); await _mockSearchRepo.Received().SearchTopics(query, noViewIDs, SearchType.Rank, 1, 20); } [Fact] public async Task GetTopicsOutsCorrectPagerContextAndValidResult() { var query = "test"; var user = new User(); var noViewIDs = new List { 1 }; var list = new List(); var service = GetService(); _mockForumService.GetNonViewableForumIDs(user).Returns(Task.FromResult(noViewIDs)); _mockSettingsManager.Current.TopicsPerPage.Returns(20); var count = 50; _mockSearchRepo.SearchTopics(query, noViewIDs, SearchType.Rank, 21, 20).Returns(Tuple.Create(new Response>(list), count)); var (response, pagerContext) = await service.GetTopics(query, SearchType.Rank, user, false, 2); Assert.Equal(20, pagerContext.PageSize); Assert.Equal(2, pagerContext.PageIndex); Assert.Equal(3, pagerContext.PageCount); Assert.True(response.IsValid); Assert.Same(list, response.Data); } [Fact] public async Task GetTopicsReturnsEmptyResultIsValidFalseAndAnemicPagerContext() { var query = "test"; var user = new User(); var noViewIDs = new List { 1 }; var service = GetService(); _mockForumService.GetNonViewableForumIDs(user).Returns(Task.FromResult(noViewIDs)); _mockSettingsManager.Current.TopicsPerPage.Returns(20); var count = 50; _mockSearchRepo.SearchTopics(query, noViewIDs, SearchType.Rank, 21, 20).Returns(Tuple.Create(new Response>(new List(), false), count)); var (response, pagerContext) = await service.GetTopics(query, SearchType.Rank, user, false, 2); Assert.Empty(response.Data); Assert.False(response.IsValid); Assert.Equal(1, pagerContext.PageSize); Assert.Equal(1, pagerContext.PageIndex); Assert.Equal(1, pagerContext.PageCount); } } ================================================ FILE: src/PopForums.Test/Services/SecurityLogServiceTests.cs ================================================ namespace PopForums.Test.Services; public class SecurityLogServiceTests { private ISecurityLogRepository _securityLogRepo; private IUserRepository _userRepo; private SecurityLogService GetService() { _securityLogRepo = Substitute.For(); _userRepo = Substitute.For(); return new SecurityLogService(_securityLogRepo, _userRepo); } [Fact] public async Task GetEntriesByUserID() { var service = GetService(); const int id = 123; await service.GetLogEntriesByUserID(id, DateTime.MinValue, DateTime.MaxValue); await _securityLogRepo.Received().GetByUserID(id, DateTime.MinValue, DateTime.MaxValue); } [Fact] public async Task GetEntriesByUserName() { var service = GetService(); const int id = 123; const string name = "jeff"; _userRepo.GetUserByName(name).Returns(Task.FromResult(new User { UserID = id, Name = name})); await service.GetLogEntriesByUserName(name, DateTime.MinValue, DateTime.MaxValue); await _securityLogRepo.Received().GetByUserID(id, DateTime.MinValue, DateTime.MaxValue); } [Fact] public async Task CreateNullIp() { var service = GetService(); await Assert.ThrowsAsync(() => service.CreateLogEntry(new User(), new User(), null, "", SecurityLogType.Undefined)); } [Fact] public async Task CreateNullMessage() { var service = GetService(); await Assert.ThrowsAsync(() => service.CreateLogEntry(new User(), new User(), "", null, SecurityLogType.Undefined)); } [Fact] public async Task Create() { var service = GetService(); SecurityLogEntry entry = null; await _securityLogRepo.Create(Arg.Do(x => entry = x)); await service.CreateLogEntry(new User { UserID = 1 }, new User { UserID = 2 }, "123", "msg", SecurityLogType.Undefined); Assert.Equal(1, entry.UserID); Assert.Equal(2, entry.TargetUserID); Assert.Equal("123", entry.IP); Assert.Equal("msg", entry.Message); Assert.Equal(SecurityLogType.Undefined, entry.SecurityLogType); } } ================================================ FILE: src/PopForums.Test/Services/SetupServiceTests.cs ================================================ namespace PopForums.Test.Services; public class SetupServiceTests { private ISetupRepository _setupRepository; private IUserService _userService; private ISettingsManager _settingsManager; private IProfileService _profileService; private SetupService GetService() { // kind of gross leaky abstraction here var staticSetupIndicator = typeof(SetupService).GetField("_isConnectionSetupGood", BindingFlags.Static | BindingFlags.NonPublic); staticSetupIndicator.SetValue(null, null); _setupRepository = Substitute.For(); _userService = Substitute.For(); _settingsManager = Substitute.For(); _profileService = Substitute.For(); return new SetupService(_setupRepository, _userService, _settingsManager, _profileService); } public class IsRuntimeConnectionAndSetupGoodTests : SetupServiceTests { [Fact] public void GoodConnectionAndSetupAlwaysReturnsTrue() { var service = GetService(); _setupRepository.IsConnectionPossible().Returns(true); _setupRepository.IsDatabaseSetup().Returns(true); var result1 = service.IsRuntimeConnectionAndSetupGood(); var result2 = service.IsRuntimeConnectionAndSetupGood(); Assert.True(result1); Assert.True(result2); } [Fact] public void GoodConnectionAndSetupOnlyCallsReposOnce() { var service = GetService(); _setupRepository.IsConnectionPossible().Returns(true); _setupRepository.IsDatabaseSetup().Returns(true); service.IsRuntimeConnectionAndSetupGood(); service.IsRuntimeConnectionAndSetupGood(); service.IsRuntimeConnectionAndSetupGood(); service.IsRuntimeConnectionAndSetupGood(); service.IsRuntimeConnectionAndSetupGood(); _setupRepository.Received().IsDatabaseSetup(); _setupRepository.Received().IsConnectionPossible(); } [Fact] public void BadConnectionReturnsFalse() { var service = GetService(); _setupRepository.IsConnectionPossible().Returns(false); var result = service.IsRuntimeConnectionAndSetupGood(); Assert.False(result); } [Fact] public void GoodConnectionButNotSetupReturnsFalse() { var service = GetService(); _setupRepository.IsConnectionPossible().Returns(true); _setupRepository.IsDatabaseSetup().Returns(false); var result = service.IsRuntimeConnectionAndSetupGood(); Assert.False(result); } } } ================================================ FILE: src/PopForums.Test/Services/SitemapServiceTests.cs ================================================ namespace PopForums.Test.Services; public class SitemapServiceTests { private SitemapService GetService() { _topicRepo = Substitute.For(); _forumRepo = Substitute.For(); return new SitemapService(_topicRepo, _forumRepo); } private ITopicRepository _topicRepo; private IForumRepository _forumRepo; public class GetSitemapPageCount : SitemapServiceTests { [Fact] public async Task ZeroTopicsReturns1() { var service = GetService(); var list = new Dictionary>(); _forumRepo.GetForumViewRestrictionRoleGraph().Returns(Task.FromResult(list)); _topicRepo.GetTopicCount(false, Arg.Any>()).Returns(Task.FromResult(0)); var pageCount = await service.GetSitemapPageCount(); Assert.Equal(1, pageCount); } [Fact] public async Task MaxTopicsReturns1() { var service = GetService(); var list = new Dictionary>(); _forumRepo.GetForumViewRestrictionRoleGraph().Returns(Task.FromResult(list)); _topicRepo.GetTopicCount(false, Arg.Any>()).Returns(Task.FromResult(30000)); var pageCount = await service.GetSitemapPageCount(); Assert.Equal(1, pageCount); } [Fact] public async Task MaxPlusOneTopicsReturns2() { var service = GetService(); var list = new Dictionary>(); _forumRepo.GetForumViewRestrictionRoleGraph().Returns(Task.FromResult(list)); _topicRepo.GetTopicCount(false, Arg.Any>()).Returns(Task.FromResult(30001)); var pageCount = await service.GetSitemapPageCount(); Assert.Equal(2, pageCount); } [Fact] public async Task NonViewableListPassedToTopicRepoForCount() { var service = GetService(); var list = new Dictionary> { {1, new List()}, {2, new List{"Admin"}}, {3, new List{"Admin","Moderator"}}, {4, new List()} }; _forumRepo.GetForumViewRestrictionRoleGraph().Returns(Task.FromResult(list)); var returnList = new List(); _topicRepo.GetTopicCount(false, Arg.Do>(x => returnList = x)).Returns(Task.FromResult(30001)); await service.GetSitemapPageCount(); Assert.Equal(2, returnList.Count); Assert.Equal(2, returnList[0]); Assert.Equal(3, returnList[1]); } } } ================================================ FILE: src/PopForums.Test/Services/SubscribeNotificationWorkerTests.cs ================================================ using NSubstitute.ReturnsExtensions; namespace PopForums.Test.Services; public class SubscribeNotificationWorkerTests { private ISubscribeNotificationRepository _subscribeNotificationRepository; private ISubscribedTopicsService _subscribedTopicsService; private INotificationAdapter _notificationAdapter; private IErrorLog _errorLog; private SubscribeNotificationWorker GetWorker() { _subscribeNotificationRepository = Substitute.For(); _subscribedTopicsService = Substitute.For(); _notificationAdapter = Substitute.For(); _errorLog = Substitute.For(); return new SubscribeNotificationWorker(_subscribeNotificationRepository, _subscribedTopicsService, _notificationAdapter, _errorLog); } [Fact] public void NoPaylodNoOtherCalls() { var worker = GetWorker(); _subscribeNotificationRepository.Dequeue().ReturnsNull(); worker.Execute(); _subscribedTopicsService.DidNotReceiveWithAnyArgs().GetSubscribedUserIDs(Arg.Any()); _notificationAdapter.DidNotReceiveWithAnyArgs().Reply(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); _errorLog.DidNotReceiveWithAnyArgs().Log(Arg.Any(), Arg.Any()); } [Fact] public void PayloadValuesCallNotificationAdapter() { var worker = GetWorker(); var payload = new SubscribeNotificationPayload { TopicID = 1, PostingUserName = "Diana", PostingUserID = 7, TopicTitle = "blah", TenantID = "pf"}; var userIDs = new List { 2, 3 }; _subscribeNotificationRepository.Dequeue().Returns(payload); _subscribedTopicsService.GetSubscribedUserIDs(payload.TopicID).Returns(userIDs); worker.Execute(); _notificationAdapter.Received().Reply(payload.PostingUserName, payload.TopicTitle, payload.TopicID, userIDs[0], payload.TenantID); _notificationAdapter.Received().Reply(payload.PostingUserName, payload.TopicTitle, payload.TopicID, userIDs[1], payload.TenantID); _errorLog.DidNotReceiveWithAnyArgs().Log(Arg.Any(), Arg.Any()); } } ================================================ FILE: src/PopForums.Test/Services/SubscribedTopicsServiceTests.cs ================================================ namespace PopForums.Test.Services; public class SubscribedTopicsServiceTests { private ISubscribedTopicsRepository _mockSubRepo; private ISettingsManager _mockSettingsManager; private INotificationAdapter _mockNotificationAdapter; private ISubscribeNotificationRepository _subNotificationRepo; private SubscribedTopicsService GetService() { _mockSubRepo = Substitute.For(); _mockSettingsManager = Substitute.For(); _mockNotificationAdapter = Substitute.For(); _subNotificationRepo = Substitute.For(); return new SubscribedTopicsService(_mockSubRepo, _mockSettingsManager, _mockNotificationAdapter, _subNotificationRepo); } [Fact] public async Task AddSubTopic() { var service = GetService(); var user = new User { UserID = 123 }; var topic = new Topic { TopicID = 456 }; _mockSubRepo.IsTopicSubscribed(user.UserID, topic.TopicID).Returns(Task.FromResult(false)); await service.AddSubscribedTopic(user.UserID, topic.TopicID); await _mockSubRepo.Received().AddSubscribedTopic(user.UserID, topic.TopicID); } [Fact] public async Task DoNotAddSubTopicIfAlreadySub() { var service = GetService(); var user = new User { UserID = 123 }; var topic = new Topic { TopicID = 456 }; _mockSubRepo.IsTopicSubscribed(user.UserID, topic.TopicID).Returns(Task.FromResult(true)); await service.AddSubscribedTopic(user.UserID, topic.TopicID); await _mockSubRepo.DidNotReceive().AddSubscribedTopic(user.UserID, topic.TopicID); } [Fact] public async Task RemoveSubTopic() { var service = GetService(); var user = new User { UserID = 123 }; var topic = new Topic { TopicID = 456 }; await service.RemoveSubscribedTopic(user, topic); await _mockSubRepo.Received().RemoveSubscribedTopic(user.UserID, topic.TopicID); } [Fact] public async Task TryRemoveSubTopic() { var service = GetService(); var user = new User { UserID = 123 }; var topic = new Topic { TopicID = 456 }; await service.TryRemoveSubscribedTopic(user, topic); await _mockSubRepo.Received().RemoveSubscribedTopic(user.UserID, topic.TopicID); } [Fact] public async Task TryRemoveSubTopicNullTopic() { var service = GetService(); var user = new User { UserID = 123 }; await service.TryRemoveSubscribedTopic(user, null); await _mockSubRepo.DidNotReceive().RemoveSubscribedTopic(Arg.Any(), Arg.Any()); } [Fact] public async Task TryRemoveSubTopicNullUser() { var service = GetService(); var topic = new Topic { TopicID = 456 }; await service.TryRemoveSubscribedTopic(null, topic); await _mockSubRepo.DidNotReceive().RemoveSubscribedTopic(Arg.Any(), Arg.Any()); } [Fact] public async Task GetTopicsFromRepo() { var user = new User { UserID = 123 }; var service = GetService(); var settings = new Settings { TopicsPerPage = 20 }; _mockSettingsManager.Current.Returns(settings); var list = new List(); _mockSubRepo.GetSubscribedTopics(user.UserID, 1, 20).Returns(Task.FromResult(list)); var (result, _) = await service.GetTopics(user, 1); Assert.Same(list, result); } [Fact] public async Task GetTopicsStartRowCalcd() { var user = new User { UserID = 123 }; var service = GetService(); var settings = new Settings { TopicsPerPage = 20 }; _mockSettingsManager.Current.Returns(settings); var (_, pagerContext) = await service.GetTopics(user, 3); await _mockSubRepo.Received().GetSubscribedTopics(user.UserID, 41, 20); Assert.Equal(20, pagerContext.PageSize); } } ================================================ FILE: src/PopForums.Test/Services/TextParsingServiceCleanForumCodeTests.cs ================================================ namespace PopForums.Test.Services; public class TextParsingServiceCleanForumCodeTests { private TextParsingService GetService() { _mockSettingsManager = Substitute.For(); _settings = new Settings(); _mockSettingsManager.Current.Returns(_settings); return new TextParsingService(_mockSettingsManager); } private ISettingsManager _mockSettingsManager; private Settings _settings; [Fact] public void FilterDupeLineBreaks() { var service = GetService(); var result = service.CleanForumCode("ahoihfohfo\r\noishfoihg\r\n\r\n\r\nehufhffh \r\n\r\n\r\n\r\nbbb"); Assert.Equal("ahoihfohfo\r\noishfoihg\r\n\r\nehufhffh \r\n\r\nbbb", result); } [Fact] public void LeaveNormalLineBreaks() { var service = GetService(); var result = service.CleanForumCode("first\r\n\r\nsecond"); Assert.Equal("first\r\n\r\nsecond", result); } [Fact] public void ConvertLonelyCarriageReturn() { var service = GetService(); var result = service.CleanForumCode("first\nsecond\r\nthird"); Assert.Equal("first\r\nsecond\r\nthird", result); } [Fact] public void RemoveImageTagIfImagesDisallowed() { var service = GetService(); _settings.AllowImages = false; var result = service.CleanForumCode("fff[i]blah[/i] f f8whef 98wy 8wyef [image=blah.jpg]asfd affs[i]blah[/i]"); Assert.Equal("fff[i]blah[/i] f f8whef 98wy 8wyef asfd affs[i]blah[/i]", result); } [Fact] public void AllowWellFormedImageTag() { var service = GetService(); _settings.AllowImages = true; var result = service.CleanForumCode("fff[i]blah[/i] f f8whef [image=\"blah.jpg\"] 98wy 8wyef [image=blah.jpg]asfd affs[i]blah[/i]"); Assert.Equal("fff[i]blah[/i] f f8whef [image=\"blah.jpg\"] 98wy 8wyef [image=blah.jpg]asfd affs[i]blah[/i]", result); } [Fact] public void RemoveMalFormedImageTag() { var service = GetService(); _settings.AllowImages = true; var result = service.CleanForumCode("fff[i]blah[/i] f f8whef [image \"blah.jpg\"] 98wy 8wyef [image=blah.jpg]asfd [image=\"blah.jpg\"]affs[i]blah[/i]"); Assert.Equal("fff[i]blah[/i] f f8whef 98wy 8wyef [image=blah.jpg]asfd [image=\"blah.jpg\"]affs[i]blah[/i]", result); } [Fact] public void CloseUnclosedTag() { var service = GetService(); var result = service.CleanForumCode("eorifj e oeihf eorhf [b]eoeirf eriojf"); Assert.Equal("eorifj e oeihf eorhf [b]eoeirf eriojf[/b]", result); } [Fact] public void CloseMultipleUnclosedTags() { var service = GetService(); var result = service.CleanForumCode("eo[ul]rifj [i]e[/i] oeihf eorhf [b]eoeirf [i]eriojf"); Assert.Equal("eo[ul]rifj [i]e[/i] oeihf eorhf [b]eoeirf [i]eriojf[/i][/b][/ul]", result); } [Fact] public void CleanUpOverlappingTags() { var service = GetService(); var result = service.CleanForumCode("eo[ul]rifj [i]e[/ul]asdfg[/i] oeihf eorhf [b]eoeirf [i]eriojf[/i][/b]"); Assert.Equal("eo[ul]rifj [i]e[/i][/ul][i]asdfg[/i] oeihf eorhf [b]eoeirf [i]eriojf[/i][/b]", result); } [Fact] public void CleanUpOverlappingTags2() { var service = GetService(); var result = service.CleanForumCode("now is [i]the time to [b]write good[/i] tests [li]and make[/b] sure that [b][i]everything[/li] is awesome[/i][/b] and stuff"); Assert.Equal("now is [i]the time to [b]write good[/b][/i][b] tests [li]and make[/li][/b][li] sure that [b][i]everything[/i][/b][/li][b][i] is awesome[/i][/b] and stuff", result); } [Fact] public void ClosingTagWithoutOpener() { var service = GetService(); var result = service.CleanForumCode("ohf whfwofhw h whfweohf[b]oihfowihfwf[/b] ihfwhf [/i]"); Assert.Equal("[i]ohf whfwofhw h whfweohf[b]oihfowihfwf[/b] ihfwhf [/i]", result); } [Fact] public void ClosingTagWithoutOpener2() { var service = GetService(); var result = service.CleanForumCode("ohf whfwofhw h whfweohf[b]oihfowihfwf[/b] ihfwhf [/i][/li]"); Assert.Equal("[li][i]ohf whfwofhw h whfweohf[b]oihfowihfwf[/b] ihfwhf [/i][/li]", result); } [Fact] public void UrlTagOk() { var service = GetService(); var result = service.CleanForumCode("ohf whfwofhw h whfweohf[url=\"http://popw.com/\"]oihfo[b]wihfwf[/url] ihfwhf[/b]"); Assert.Equal("ohf whfwofhw h whfweohf[url=\"http://popw.com/\"]oihfo[b]wihfwf[/b][/url][b] ihfwhf[/b]", result); } [Fact] public void IgnoreInvalidTag() { var service = GetService(); var result = service.CleanForumCode("ohf i oih hgoehi[bad]gaeiorw iowh owahfaowhfwohf"); Assert.Equal("ohf i oih hgoehi[bad]gaeiorw iowh owahfaowhfwohf", result); } [Fact] public void TagUrlWithProtocol() { var service = GetService(); var result = service.CleanForumCode("ohf i oih hgoehi http://popw.com/ owahfaowhfwohf"); Assert.Equal("ohf i oih hgoehi [url=http://popw.com/]http://popw.com/[/url] owahfaowhfwohf", result); } [Fact] public void TagLongUrlWithProtocol() { var service = GetService(); var result = service.CleanForumCode("ohf i oih hgoehi http://popw.com/1234567890123456789012345678901234567890123456789012345678901234567890 owahfaowhfwohf"); Assert.Equal("ohf i oih hgoehi [url=http://popw.com/1234567890123456789012345678901234567890123456789012345678901234567890]http://popw.com/12345678901...1234567890[/url] owahfaowhfwohf", result); } [Fact] public void TagWwwUrl() { var service = GetService(); var result = service.CleanForumCode("ohf i oih hgoehi www.popw.com owahfaowhfwohf"); Assert.Equal("ohf i oih hgoehi [url=https://www.popw.com]www.popw.com[/url] owahfaowhfwohf", result); } [Fact] public void TagLongWwwUrl() { var service = GetService(); var result = service.CleanForumCode("ohf i oih hgoehi www.popw.com/1234567890123456789012345678901234567890123456789012345678901234567890 owahfaowhfwohf"); Assert.Equal("ohf i oih hgoehi [url=https://www.popw.com/1234567890123456789012345678901234567890123456789012345678901234567890]www.popw.com/12345678901234...1234567890[/url] owahfaowhfwohf", result); } [Fact] public void TagEmailUrl() { var service = GetService(); var result = service.CleanForumCode("ohf i oih hgoehi jeff@popw.com owahfaowhfwohf"); Assert.Equal("ohf i oih hgoehi [url=mailto:jeff@popw.com]jeff@popw.com[/url] owahfaowhfwohf", result); } [Fact] public void EscapeHtml() { var service = GetService(); var result = service.CleanForumCode("ohf i oih hgoehi indeed owahfaowhfwohf"); Assert.Equal("ohf i oih hgoehi <a href=\"javascript:alert('blah')\">indeed</a> owahfaowhfwohf", result); } [Fact] public void DontCreateUrlOpenForOrphanCloser() { var service = GetService(); var result = service.CleanForumCode("test [b]test[/url] test[/b]"); Assert.Equal("test [b]test test[/b]", result); } [Fact] public void DoubleHttpArchiveUrl() { var service = GetService(); var result = service.CleanForumCode("blah http://web.archive.org/web/20001002225219/http://coasterbuzz.com/forums/ blah"); Assert.Equal("blah [url=http://web.archive.org/web/20001002225219/http://coasterbuzz.com/forums/]http://web.archive.org/web/...om/forums/[/url] blah", result); } [Fact] public void DoubleHttpArchiveUrl2() { var service = GetService(); var result = service.CleanForumCode("[url=https://web.archive.org/web/20120703090507/http://coasterbuzz.com/Forums/ForumPhoto/PhotoDetail/kings-dominion-2012?p=864820]suck[/url]"); Assert.Equal("[url=https://web.archive.org/web/20120703090507/http://coasterbuzz.com/Forums/ForumPhoto/PhotoDetail/kings-dominion-2012?p=864820]suck[/url]", result); } [Fact] public void YouTubeHttpOnYouTubeDomain() { var service = GetService(); _settings.AllowImages = true; var result = service.CleanForumCode("blah http://youtube.com/watch?v=12345 blah"); Assert.Equal("blah [youtube=http://youtube.com/watch?v=12345] blah", result); } [Fact] public void YouTubeHttpOnWwwYouTubeDomain() { var service = GetService(); _settings.AllowImages = true; var result = service.CleanForumCode("blah http://www.youtube.com/watch?v=12345 blah"); Assert.Equal("blah [youtube=http://www.youtube.com/watch?v=12345] blah", result); } [Fact] public void YouTubeHttpsOnYouTubeDomain() { var service = GetService(); _settings.AllowImages = true; var result = service.CleanForumCode("blah https://youtube.com/watch?v=12345 blah"); Assert.Equal("blah [youtube=https://youtube.com/watch?v=12345] blah", result); } [Fact] public void YouTubeHttpsOnWwwYouTubeDomain() { var service = GetService(); _settings.AllowImages = true; var result = service.CleanForumCode("blah https://www.youtube.com/watch?v=12345 blah"); Assert.Equal("blah [youtube=https://www.youtube.com/watch?v=12345] blah", result); } [Fact] public void YouTubeHttpOnShortYouTubeDomain() { var service = GetService(); _settings.AllowImages = true; var result = service.CleanForumCode("blah http://youtu.be/12345 blah"); Assert.Equal("blah [youtube=http://youtu.be/12345] blah", result); } [Fact] public void YouTubeHttpsOnShortYouTubeDomain() { var service = GetService(); _settings.AllowImages = true; var result = service.CleanForumCode("blah https://youtu.be/12345 blah"); Assert.Equal("blah [youtube=https://youtu.be/12345] blah", result); } [Fact] public void YouTubeDoesntEmbedWhenUrlIsForShorts() { var service = GetService(); _settings.AllowImages = true; var result = service.CleanForumCode("blah https://youtube.com/shorts/0c-5EGX6B2E?si=8OoYCc7x5x_rHiVM blah"); Assert.Equal("blah [url=https://youtube.com/shorts/0c-5EGX6B2E?si=8OoYCc7x5x_rHiVM]https://youtube.com/shorts/...7x5x_rHiVM[/url] blah", result); } [Fact] public void YouTubeDoesntEmbedWhenUrlIsForChannel() { var service = GetService(); _settings.AllowImages = true; var result = service.CleanForumCode("blah https://www.youtube.com/@sillynonsense blah"); Assert.Equal("blah [url=https://www.youtube.com/@sillynonsense]https://www.youtube.com/@sillynonsense[/url] blah", result); } [Fact] public void YouTubeDoesntEmbedWhenUrlIsForPost() { var service = GetService(); _settings.AllowImages = true; var result = service.CleanForumCode("blah https://www.youtube.com/post/Ugkx-hR1fdEJqSWSGTwzpoU4GcT_4ktnY_Qy blah"); Assert.Equal("blah [url=https://www.youtube.com/post/Ugkx-hR1fdEJqSWSGTwzpoU4GcT_4ktnY_Qy]https://www.youtube.com/pos...T_4ktnY_Qy[/url] blah", result); } [Fact] public void YouTubeLinkParsedToLinkWithImagesOff() { var service = GetService(); _settings.AllowImages = false; var result = service.CleanForumCode("blah https://youtu.be/12345 blah"); Assert.Equal("blah [url=https://youtu.be/12345]https://youtu.be/12345[/url] blah", result); } [Fact] public void YouTubeLinkInUrlTagNotParsed() { var service = GetService(); _settings.AllowImages = true; var result = service.CleanForumCode("blah [url=https://youtu.be/12345]test[/url] blah"); Assert.Equal("blah [url=https://youtu.be/12345]test[/url] blah", result); } } ================================================ FILE: src/PopForums.Test/Services/TextParsingServiceClientHtmlToForumCodeTests.cs ================================================ using SixLabors.ImageSharp; namespace PopForums.Test.Services; public class TextParsingServiceClientHtmlToForumCodeTests { private TextParsingService GetService() { _mockSettingsManager = Substitute.For(); _settings = new Settings(); _mockSettingsManager.Current.Returns(_settings); return new TextParsingService(_mockSettingsManager); } private ISettingsManager _mockSettingsManager; private Settings _settings; [Fact] public void RemoveLineBreaks() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test

    \r\n

    test

    "); Assert.Equal("test\r\n\r\ntest", result); } [Fact] public void RemoveEmptyLinesWithOnlySpace() { var service = GetService(); var result = service.ClientHtmlToForumCode("

     

    \r\n

     

    "); Assert.Equal(result, string.Empty); } [Fact] public void DitchStartAndEndPara() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test

    "); Assert.Equal("test", result); } [Fact] public void PutQuoteOnItsOwnLines() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test

    quote

    test

    "); Assert.Equal("test\r\n\r\n[quote]\r\nquote\r\n[/quote]\r\n\r\ntest", result); } [Fact] public void StartAndEndParaWithBreaks() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test

    test

    "); Assert.Equal("test\r\n\r\ntest", result); } [Fact] public void SingleLineBreaks() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test
    test
    test
    test

    "); Assert.Equal("test\r\ntest\r\ntest\r\ntest", result); } [Fact] public void LineBreakInAndOutOfQuote() { var service = GetService(); var result = service.ClientHtmlToForumCode("
    quote
    "); Assert.Equal("[quote]\r\nquote\r\n[/quote]", result); } [Fact] public void ItalicVariations() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test test test test

    "); Assert.Equal("test [i]test[/i] [i]test[/i] test", result); } [Fact] public void BoldVariations() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test test test test

    "); Assert.Equal("test [b]test[/b] [b]test[/b] test", result); } [Fact] public void Code() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test test test

    "); Assert.Equal("test [code]test[/code] test", result); } [Fact] public void Pre() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test

    test
    test

    "); Assert.Equal("test [pre]test[/pre] test", result); } [Fact] public void Li() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test

  • test
  • test

    "); Assert.Equal("test [li]test[/li] test", result); } [Fact] public void Ol() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test

      test
    test

    "); Assert.Equal("test [ol]test[/ol] test", result); } [Fact] public void Ul() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test

      test
    test

    "); Assert.Equal("test [ul]test[/ul] test", result); } [Fact] public void AnchorToUrl() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test test test

    "); Assert.Equal("test [url=http://popw.com/]test[/url] test", result); } [Fact] public void AnchorToUrlWithTarget() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test test test

    "); Assert.Equal("test [url=http://popw.com/]test[/url] test", result); } [Fact] public void AnchorWithDoubleProtocol() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    suck

    "); Assert.Equal("[url=https://web.archive.org/web/20120703090507/http://coasterbuzz.com/Forums/ForumPhoto/PhotoDetail/kings-dominion-2012?p=864820]suck[/url]", result); } [Fact] public void AnchorToUrlWithTargetNoQuotes() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test test test

    "); Assert.Equal("test [url=http://popw.com/]test[/url] test", result); } [Fact] public void ImageNoClose() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test \"\" test

    "); Assert.Equal("test [image=blah.jpg] test", result); } [Fact] public void ImageNoCloseNoSpace() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test test

    "); Assert.Equal("test [image=blah.jpg] test", result); } [Fact] public void ImageNoCloseSpace() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test test

    "); Assert.Equal("test [image=blah.jpg] test", result); } [Fact] public void ImageOtherAttribute() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test test

    "); Assert.Equal("test [image=blah.jpg] test", result); result = service.ClientHtmlToForumCode("

    test test

    "); Assert.Equal("test [image=blah.jpg] test", result); } [Fact] public void ImageOtherAttributeAfterSrc() { var service = GetService(); var result = service.ClientHtmlToForumCode(@"

    image:

    "); Assert.Equal("image:\r\n\r\n[image=https://popforums.com/img/hugcloud.png]", result); } [Fact] public void ImageOtherAttributeBeforeAndAfterSrc() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test test

    "); Assert.Equal("test [image=blah.jpg] test", result); } [Fact] public void NukeInvalidHtml() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test test

    "); Assert.Equal("test alert('blah'); test", result); } [Fact] public void NukeInvalidHtml2() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    [quote]WolfBobs said:
    People need to learn to not blindly follow people based on red and blue. That's why our country has been stagnant with no real change for the good of the people for decades. Good for corporations? Sure. But the people? Not so much.

    [/quote]It's reassuring to see that some people feel that way.

    "); Assert.Equal("[quote]\r\n[i]WolfBobs said:[/i]\r\nPeople need to learn to not blindly follow people based on red and blue. That's why our country has been stagnant with no real change for the good of the people for decades. Good for corporations? Sure. But the people? Not so much.\r\n\r\n\r\n[/quote]It's reassuring to see that some people feel that way.", result); } [Fact] public void ConvertHtmlEscapes() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test < > &   test

    "); Assert.Equal("test < > & test", result); } [Fact] public void RemoveLineBreaksInList() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    blah

      \n
    • first
    • \n
    • second
    • \n
    • third
    • \n

    blah

    "); Assert.Equal("blah\r\n\r\n[ul][li]first[/li][li]second[/li][li]third[/li][/ul]\r\n\r\nblah", result); } [Fact] public void YouTubeUnparse() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test

    test

    "); Assert.Equal("test\r\n\r\n[youtube=https://www.youtube.com/watch?v=789]\r\n\r\ntest", result); } [Fact] public void YouTubeUnparseHttps() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test

    test

    "); Assert.Equal("test\r\n\r\n[youtube=https://www.youtube.com/watch?v=789]\r\n\r\ntest", result); } [Fact] public void ParseImageWithExtraAttributesLikeAlt() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    test

    \r\n

    \"\"

    test

    "); Assert.Equal("test\r\n\r\n[image=https://scontent.ftpa1-2.fna.fbcdn.net/v/t31.0-8/12119905_10153331542212955_4087525267669435874_o.jpg?_nc_cat=104&_nc_ht=scontent.ftpa1-2.fna&oh=bde1d73b39027f410a9506c19dfb4428&oe=5D95ACD5]\r\n\r\ntest", result); } [Fact] public void ParseSequentialImages() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    "); Assert.Equal("[image=test1.jpg][image=test2.jpg]", result); } [Fact] public void ParseNonSequentialImages() { var service = GetService(); var result = service.ClientHtmlToForumCode("

    "); Assert.Equal("[image=test1.jpg]\r\n\r\n[image=test2.jpg]", result); } } ================================================ FILE: src/PopForums.Test/Services/TextParsingServiceForumCodeToHtmlTests.cs ================================================ namespace PopForums.Test.Services; public class TextParsingServiceForumCodeToHtmlTests { private TextParsingService GetService() { _mockSettingsManager = Substitute.For(); _settings = new Settings(); _mockSettingsManager.Current.Returns(_settings); return new TextParsingService(_mockSettingsManager); } private ISettingsManager _mockSettingsManager; private Settings _settings; [Fact] public void UrlWithQuotes() { var service = GetService(); var result = service.CleanForumCodeToHtml("this is [url=\"http://popw.com/\"]my link[/url]."); Assert.Equal("

    this is my link.

    ", result); } [Fact] public void UrlWithoutQuotes() { var service = GetService(); var result = service.CleanForumCodeToHtml("this is [url=http://popw.com/]my link[/url]."); Assert.Equal("

    this is my link.

    ", result); } [Fact] public void MailLinkWithQuotes() { var service = GetService(); var result = service.CleanForumCodeToHtml("this is [url=\"mailto:jeff@popw.com\"]my link[/url]."); Assert.Equal("

    this is my link.

    ", result); } [Fact] public void MailLinkWithoutQuotes() { var service = GetService(); var result = service.CleanForumCodeToHtml("this is [url=mailto:jeff@popw.com]my link[/url]."); Assert.Equal("

    this is my link.

    ", result); } [Fact] public void DitchNaughtyJavascriptLinkWithQuotes() { var service = GetService(); var result = service.CleanForumCodeToHtml("this is [url=\"javascript:alert('blah')\"]my link[/url]."); Assert.Equal("

    this is my link.

    ", result); } [Fact] public void DitchNaughtyJavascriptLinkWithoutQuotes() { var service = GetService(); var result = service.CleanForumCodeToHtml("this is [url=javascript:alert('blah')]my link[/url]."); Assert.Equal("

    this is my link.

    ", result); } [Fact] public void DitchNaughtyJavascriptLinkUpperCase() { var service = GetService(); var result = service.CleanForumCodeToHtml("this is [url=JAVASCRIPT:alert('blah')]my link[/url]."); Assert.Equal("

    this is my link.

    ", result); } [Fact] public void DitchNaughtyJavascriptLinkMixedCase() { var service = GetService(); var result = service.CleanForumCodeToHtml("this is [url=Javascript:alert('blah')]my link[/url]."); Assert.Equal("

    this is my link.

    ", result); } [Fact] public void ReplaceImageTagsWithQuotes() { var service = GetService(); _settings.AllowImages = true; var result = service.CleanForumCodeToHtml("check out the image [image=\"my.jpg\"] here"); Assert.Equal("

    check out the image here

    ", result); } [Fact] public void ReplaceImageTagsWithoutQuotes() { var service = GetService(); _settings.AllowImages = true; var result = service.CleanForumCodeToHtml("check out the image [image=my.jpg] here"); Assert.Equal("

    check out the image here

    ", result); } [Fact] public void RemoveImageTagsWithQuotes() { var service = GetService(); _settings.AllowImages = false; var result = service.CleanForumCodeToHtml("check out the image [image=\"my.jpg\"] here"); Assert.Equal("

    check out the image here

    ", result); } [Fact] public void RemoveImageTagsWithoutQuotes() { var service = GetService(); _settings.AllowImages = false; var result = service.CleanForumCodeToHtml("check out the image [image=my.jpg] here"); Assert.Equal("

    check out the image here

    ", result); } [Fact] public void ParseClassicImgTags() { var service = GetService(); _settings.AllowImages = true; var result = service.CleanForumCodeToHtml("check out the image [img]http://coasterbuzz.com/CoasterPhoto/CoasterPhotoImage/4800[/img] here"); Assert.Equal("

    check out the image here

    ", result); } [Fact] public void ParseAllThreeImageVariants() { var service = GetService(); _settings.AllowImages = true; var result = service.CleanForumCodeToHtml("[image=http://coasterbuzz.com/CoasterPhoto/CoasterPhotoImage/4800]\r\n\r\n[image=\"http://coasterbuzz.com/CoasterPhoto/CoasterPhotoImage/4800\"]\r\n\r\n[img]http://coasterbuzz.com/CoasterPhoto/CoasterPhotoImage/4800[/img]"); Assert.Equal("

    ", result); } [Fact] public void ReplaceItalic() { var service = GetService(); var result = service.CleanForumCodeToHtml("this is [i]italic[/i]."); Assert.Equal("

    this is italic.

    ", result); } [Fact] public void ReplaceBold() { var service = GetService(); var result = service.CleanForumCodeToHtml("this is [b]bold[/b]."); Assert.Equal("

    this is bold.

    ", result); } [Fact] public void ReplaceCode() { var service = GetService(); var result = service.CleanForumCodeToHtml("this is [code]code[/code]."); Assert.Equal("

    this is code.

    ", result); } [Fact] public void ReplacePre() { var service = GetService(); var result = service.CleanForumCodeToHtml("this is [pre]pre[/pre]."); Assert.Equal("

    this is

    pre
    .

    ", result); } [Fact] public void ReplaceLi() { var service = GetService(); var result = service.CleanForumCodeToHtml("this is [li]li[/li]."); Assert.Equal("

    this is

  • li
  • .

    ", result); } [Fact] public void ReplaceOl() { var service = GetService(); var result = service.CleanForumCodeToHtml("this is [ol]ol[/ol]."); Assert.Equal("

    this is

      ol
    .

    ", result); } [Fact] public void ReplaceUl() { var service = GetService(); var result = service.CleanForumCodeToHtml("this is [ul]ul[/ul]."); Assert.Equal("

    this is

      ul
    .

    ", result); } [Fact] public void SurroundWithPara() { var service = GetService(); var result = service.CleanForumCodeToHtml("this is some text."); Assert.Equal("

    this is some text.

    ", result); } [Fact] public void NoParaIfStartsOrEndWithQuote() { var service = GetService(); var result = service.CleanForumCodeToHtml("[quote]this is some text.[/quote]"); Assert.False(result.StartsWith("

    ") || result.EndsWith("

    ")); } [Fact] public void ReplaceQuote() { var service = GetService(); var result = service.CleanForumCodeToHtml("this is [quote]some[/quote] text."); Assert.Equal("

    this is

    some

    text.

    ", result); } [Fact] public void DoubleLineBreakToParaEndStart() { var service = GetService(); var result = service.CleanForumCodeToHtml("this is\r\n\r\nsome text."); Assert.Equal("

    this is

    some text.

    ", result); } [Fact] public void NoDoubleLineBreakToParaEndStartIfAtQuoteEnd() { var service = GetService(); var result = service.CleanForumCodeToHtml("[quote]this is[/quote]\r\n\r\nsome text."); Assert.Equal("

    this is

    some text.

    ", result); } [Fact] public void NoDoubleLineBreakToParaEndStartIfAtQuoteStart() { var service = GetService(); var result = service.CleanForumCodeToHtml("this is\r\n\r\n[quote]some text.[/quote]"); Assert.Equal("

    this is

    some text.

    ", result); } [Fact] public void EliminateLineBreaksBetweenEndParaAndQuote() { var service = GetService(); var result = service.CleanForumCodeToHtml("test text\r\n[quote]test quote[/quote]test text\r\n\r\n[quote]test quote[/quote]"); Assert.Equal("

    test text

    test quote

    test text

    test quote

    ", result); } [Fact] public void EliminateLineBreaksBetweenEndQuoteAndQuote() { var service = GetService(); var result = service.CleanForumCodeToHtml("test text\r\n[quote]test quote[/quote]\r\n\r\n[quote]test quote[/quote]"); Assert.Equal("

    test text

    test quote

    test quote

    ", result); } [Fact] public void EliminateLineBreaksBetweenStartAndQuote() { var service = GetService(); var result = service.CleanForumCodeToHtml("\r\n\r\n[quote]test quote[/quote]"); Assert.Equal("

    test quote

    ", result); } [Fact] public void CloseParaBeforeQuote() { var service = GetService(); var result = service.CleanForumCodeToHtml("test text\r\n[quote]test quote[/quote]test text\r\n\r\n[quote]test quote[/quote]test text[quote]test quote[/quote]"); Assert.Equal("

    test text

    test quote

    test text

    test quote

    test text

    test quote

    ", result); } [Fact] public void StartInsideOfQuoteWithParaUnlessFirstThingIsSubQuoteOrPara() { var service = GetService(); var result = service.CleanForumCodeToHtml("[quote]test quote[/quote][quote][quote]test quote[/quote][/quote]"); Assert.Equal("

    test quote

    test quote

    ", result); } [Fact] public void EndInsideOfQuoteWithParaUnlessFirstThingIsSubQuote() { var service = GetService(); var result = service.CleanForumCodeToHtml("[quote]test quote[/quote][quote][quote]test quote[/quote][/quote]"); Assert.Equal("

    test quote

    test quote

    ", result); } [Fact] public void StartParaAfterQuote() { var service = GetService(); var result = service.CleanForumCodeToHtml("[quote]test quote[/quote]test text[quote]test quote[/quote]\r\ntest text[quote]test quote[/quote]\r\n\r\ntest text"); Assert.Equal("

    test quote

    test text

    test quote

    test text

    test quote

    test text

    ", result); } [Fact] public void RemoveTrailingLineBreaksBetweenEndsOfQuotes() { var service = GetService(); var result = service.CleanForumCodeToHtml("[quote][quote]test quote[/quote]\r\n\r\n[/quote][quote][quote]test quote[/quote]\r\n[/quote]"); Assert.Equal("

    test quote

    test quote

    ", result); } [Fact] public void EndParaInQuote() { var service = GetService(); var result = service.CleanForumCodeToHtml("[quote]test quote[/quote][quote]test quote\r\n[/quote][quote]test quote\r\n\r\n[/quote]"); Assert.Equal("

    test quote

    test quote

    test quote

    ", result); } [Fact] public void StartParaAfterQuote2() { var service = GetService(); var result = service.CleanForumCodeToHtml("[quote]test quote[/quote]test text[quote]test quote[/quote]\r\ntest text[quote]test quote[/quote]\r\n\r\ntest text"); Assert.Equal("

    test quote

    test text

    test quote

    test text

    test quote

    test text

    ", result); } [Fact] public void SingleLineBreak() { var service = GetService(); var result = service.CleanForumCodeToHtml("test text\r\ntest text"); Assert.Equal("

    test text
    test text

    ", result); } [Fact] public void DoubleHttpArchiveUrl() { var service = GetService(); var result = service.CleanForumCodeToHtml("blah [url=http://web.archive.org/web/20001002225219/http://coasterbuzz.com/forums/]http://web.archive.org/web/20001002225219/http://coasterbuzz.com/forums/[/url] blah"); Assert.Equal("

    blah http://web.archive.org/web/20001002225219/http://coasterbuzz.com/forums/ blah

    ", result); } [Fact] public void HttpsAndHttpArchiveUrlWithTextLink() { var service = GetService(); var result = service.CleanForumCodeToHtml("[url=https://web.archive.org/web/20120703090507/http://coasterbuzz.com/Forums/ForumPhoto/PhotoDetail/kings-dominion-2012?p=864820]sucks[/url]"); Assert.Equal("

    sucks

    ", result); } [Fact] public void YouTubeTagMainDomainConvertedToIframe() { var service = GetService(); _settings.YouTubeHeight = 123; _settings.YouTubeWidth = 456; _settings.AllowImages = true; var result = service.CleanForumCodeToHtml("test [youtube=http://youtube.com/watch?v=789] text"); Assert.Equal("

    test text

    ", result); } [Fact] public void YouTubeTagMainDomainHttpsConvertedToIframe() { var service = GetService(); _settings.YouTubeHeight = 123; _settings.YouTubeWidth = 456; _settings.AllowImages = true; var result = service.CleanForumCodeToHtml("test [youtube=https://youtube.com/watch?v=789] text"); Assert.Equal("

    test text

    ", result); } [Fact] public void YouTubeTagShortDomainConvertedToIframe() { var service = GetService(); _settings.YouTubeHeight = 123; _settings.YouTubeWidth = 456; _settings.AllowImages = true; var result = service.CleanForumCodeToHtml("test [youtube=http://youtu.be/789] text"); Assert.Equal("

    test text

    ", result); } [Fact] public void YouTubeTagShortDomainHttpsConvertedToIframe() { var service = GetService(); _settings.YouTubeHeight = 123; _settings.YouTubeWidth = 456; _settings.AllowImages = true; var result = service.CleanForumCodeToHtml("test [youtube=https://youtu.be/789] text"); Assert.Equal("

    test text

    ", result); } [Fact] public void YouTubeTagConvertedToIframe() { var service = GetService(); _settings.YouTubeHeight = 123; _settings.YouTubeWidth = 456; _settings.AllowImages = true; var result = service.ForumCodeToHtml("test test [youtube=http://www.youtube.com/watch?v=NL125lBWYc4] test"); Assert.Equal("

    test test test

    ", result); } [Fact] public void YouTubeTagHttpsConvertedToIframe() { var service = GetService(); _settings.YouTubeHeight = 123; _settings.YouTubeWidth = 456; _settings.AllowImages = true; var result = service.ForumCodeToHtml("test test [youtube=https://www.youtube.com/watch?v=NL125lBWYc4] test"); Assert.Equal("

    test test test

    ", result); } [Fact] public void YouTubePostUrlParsedAsRegularLink() { var service = GetService(); _settings.YouTubeHeight = 123; _settings.YouTubeWidth = 456; _settings.AllowImages = true; var result = service.ForumCodeToHtml("test https://www.youtube.com/post/Ugkx-hR1fdEJqSWSGTwzpoU4GcT_4ktnY_Qy text"); Assert.Equal("

    test https://www.youtube.com/pos...T_4ktnY_Qy text

    ", result); } [Fact] public void UrlWithBangParsesCorrectly() { var service = GetService(); var result = service.ForumCodeToHtml("(and [url=\"https://groups.google.com/forum/#!original/rec.roller-coaster/iwTIvU2IXKI/hKB_D9uRbaEJ\"]'millennium' is spelled with two n's[/url] regardless of whether the new one starts in 2000 or 2001.)"); Assert.Equal("

    (and 'millennium' is spelled with two n's regardless of whether the new one starts in 2000 or 2001.)

    ", result); } [Fact] public void UrlWithParenthesesParsesCorrectly() { var service = GetService(); var result = service.ForumCodeToHtml("(and [url=\"https://blahblah.com/test(test)test\"]'millennium' is spelled with two n's[/url] regardless of whether the new one starts in 2000 or 2001.)"); Assert.Equal("

    (and 'millennium' is spelled with two n's regardless of whether the new one starts in 2000 or 2001.)

    ", result); } [Fact] public void DontParagraphAnEmptyString() { var service = GetService(); var result = service.CleanForumCodeToHtml(string.Empty); Assert.Equal(result, string.Empty); } [Fact] public void UrlWithParenthesesParsed() { var service = GetService(); _settings.AllowImages = true; var result = service.ForumCodeToHtml("blah https://blahblah.com/test(test)test blah"); Assert.Equal("

    blah https://blahblah.com/test(test)test blah

    ", result); } } ================================================ FILE: src/PopForums.Test/Services/TextParsingServiceOtherTests.cs ================================================ namespace PopForums.Test.Services; public class TextParsingServiceOtherTests { private TextParsingService GetService() { _mockSettingsManager = Substitute.For(); _settings = new Settings(); _mockSettingsManager.Current.Returns(_settings); return new TextParsingService(_mockSettingsManager); } private ISettingsManager _mockSettingsManager; private Settings _settings; [Fact] public void ConvertHtmlQuoteToForumCodeQuote() { // yes, this is a test to avoid old branches that treated quotes as BB code relics var service = GetService(); var result = service.HtmlToClientHtml("

    some text

    quote text

    "); Assert.Equal("

    some text

    quote text

    ", result); } [Fact] public void RemoveAnchorTargets() { var service = GetService(); var result = service.HtmlToClientHtml("

    some text link

    "); Assert.Equal("

    some text link

    ", result); } [Fact] public void RemoveYouTubeIframe() { var service = GetService(); var result = service.HtmlToClientHtml("

    test test test

    "); Assert.Equal("

    test test https://www.youtube.com/watch?v=NL125lBWYc4 test

    ", result); } [Fact] public void RemoveYouTubeIframeHttps() { var service = GetService(); var result = service.HtmlToClientHtml("

    test test test

    "); Assert.Equal("

    test test https://www.youtube.com/watch?v=NL125lBWYc4 test

    ", result); } [Fact] public void CensorTheNaughty() { var service = GetService(); _settings.CensorWords = "shit bitch fuck"; _settings.CensorCharacter = "+"; var result = service.Censor("this is some shitty fucked up code, bitch"); Assert.Equal("this is some ++++ty ++++ed up code, +++++", result); } [Fact] public void CensorTheNaughtyCaseInsensitive() { var service = GetService(); _settings.CensorWords = "shit bitch fuck"; _settings.CensorCharacter = "+"; var result = service.Censor("this is some sHitty FucKed up code, bitCH"); Assert.Equal("this is some ++++ty ++++ed up code, +++++", result); } [Fact] public void CensorEmptyReturnsEmpty() { var service = GetService(); var result = service.Censor(String.Empty); Assert.Equal(String.Empty, result); } [Fact] public void CensorNullReturnsEmpty() { var service = GetService(); var result = service.Censor(null); Assert.Equal(String.Empty, result); } [Fact] public void ForumCodeToHtmlReturnsEmptyInsteadOfParaTags() { var service = GetService(); var result = service.ForumCodeToHtml(""); Assert.Equal(String.Empty, result); } [Fact] public void ParsedUrlWithParenthesesUnparsed() { var service = GetService(); _settings.AllowImages = true; var result = service.HtmlToClientHtml("

    blah https://blahblah.com/test(test)test blah

    "); Assert.Equal("

    blah https://blahblah.com/test(test)test blah

    ", result); } } ================================================ FILE: src/PopForums.Test/Services/TopicServiceTests.cs ================================================ namespace PopForums.Test.Services; public class TopicServiceTests { private ISettingsManager _settingsManager; private ITopicRepository _topicRepo; private IPostRepository _postRepo; private IModerationLogService _modService; private IForumService _forumService; private IEventPublisher _eventPublisher; private ISearchRepository _searchRepo; private IUserRepository _userRepo; private ISearchIndexQueueRepository _searchIndexQueueRepo; private ITenantService _tenantService; private INotificationAdapter _notificationAdapter; private TopicService GetTopicService() { _settingsManager = Substitute.For(); _topicRepo = Substitute.For(); _postRepo = Substitute.For(); _modService = Substitute.For(); _forumService = Substitute.For(); _eventPublisher = Substitute.For(); _searchRepo = Substitute.For(); _userRepo = Substitute.For(); _searchIndexQueueRepo = Substitute.For(); _tenantService = Substitute.For(); _notificationAdapter = Substitute.For(); return new TopicService(_topicRepo, _postRepo, _settingsManager, _modService, _forumService, _eventPublisher, _searchRepo, _userRepo, _searchIndexQueueRepo, _tenantService, _notificationAdapter); } private static User GetUser() { return new User { UserID = 123, Name = "Name", Email = "Email", IsApproved = true, AuthorizationKey = Guid.NewGuid(), Roles = new List()}; } [Fact] public async Task GetTopicsFromRepo() { var forum = new Forum { ForumID = 1, TopicCount = 3 }; var topicService = GetTopicService(); var repoTopics = new List(); var settings = new Settings {TopicsPerPage = 20}; _topicRepo.Get(1, true, 1, settings.TopicsPerPage).Returns(Task.FromResult(repoTopics)); _settingsManager.Current.Returns(settings); var (topics, _) = await topicService.GetTopics(forum, true, 1); Assert.Same(repoTopics, topics); } [Fact] public async Task GetTopicsStartRowCalcd() { var forum = new Forum { ForumID = 1, TopicCount = 300 }; var topicService = GetTopicService(); var settings = new Settings { TopicsPerPage = 20 }; _settingsManager.Current.Returns(settings); await topicService.GetTopics(forum, false, 3); await _topicRepo.Received().Get(Arg.Any(), Arg.Any(), 41, Arg.Any()); } [Fact] public async Task GetTopicsIncludeDeletedCallsRepoCount() { var forum = new Forum { ForumID = 1 }; var topicService = GetTopicService(); _topicRepo.Get(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(Task.FromResult(new List())); _topicRepo.GetTopicCount(1, true).Returns(Task.FromResult(350)); _settingsManager.Current.Returns(new Settings()); await topicService.GetTopics(forum, true, 3); await _topicRepo.Received().GetTopicCount(forum.ForumID, true); } [Fact] public async Task GetTopicsNotIncludeDeletedNotCallRepoCount() { var forum = new Forum { ForumID = 1 }; var topicService = GetTopicService(); _topicRepo.Get(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()).Returns(Task.FromResult(new List())); _settingsManager.Current.Returns(new Settings()); await topicService.GetTopics(forum, false, 3); await _topicRepo.DidNotReceive().GetTopicCount(forum.ForumID, false); } [Fact] public async Task GetTopicsPagerContextIncludesPageIndexAndCalcdTotalPages() { var forum = new Forum {ForumID = 1, TopicCount = 301}; var forum2 = new Forum {ForumID = 2, TopicCount = 300}; var forum3 = new Forum {ForumID = 3, TopicCount = 299}; var topicService = GetTopicService(); var settings = new Settings { TopicsPerPage = 20 }; _topicRepo.Get(Arg.Any(), Arg.Any(), Arg.Any(), settings.TopicsPerPage).Returns(Task.FromResult(new List())); _settingsManager.Current.Returns(settings); var (_, pagerContext) = await topicService.GetTopics(forum, false, 3); Assert.Equal(3, pagerContext.PageIndex); Assert.Equal(16, pagerContext.PageCount); (_, pagerContext) = await topicService.GetTopics(forum2, false, 4); Assert.Equal(4, pagerContext.PageIndex); Assert.Equal(15, pagerContext.PageCount); (_, pagerContext) = await topicService.GetTopics(forum3, false, 5); Assert.Equal(5, pagerContext.PageIndex); Assert.Equal(15, pagerContext.PageCount); Assert.Equal(settings.TopicsPerPage, pagerContext.PageSize); } [Fact] public async Task CloseTopicThrowsWithNonMod() { var topic = new Topic { TopicID = 1 }; var user = GetUser(); var topicService = GetTopicService(); await Assert.ThrowsAsync(async () => await topicService.CloseTopic(topic, user)); } [Fact] public async Task CloseTopicClosesWithMod() { var topic = new Topic { TopicID = 1 }; var user = GetUser(); user.Roles.Add(PermanentRoles.Moderator); var topicService = GetTopicService(); await topicService.CloseTopic(topic, user); await _modService.Received(1).LogTopic(user, ModerationType.TopicClose, topic, null); await _topicRepo.Received(1).CloseTopic(topic.TopicID); } [Fact] public async Task OpenTopicThrowsWithNonMod() { var topic = new Topic { TopicID = 1 }; var user = GetUser(); var topicService = GetTopicService(); await Assert.ThrowsAsync(async () => await topicService.OpenTopic(topic, user)); } [Fact] public async Task OpenTopicOpensWithMod() { var topic = new Topic { TopicID = 1 }; var user = GetUser(); user.Roles.Add(PermanentRoles.Moderator); var topicService = GetTopicService(); await topicService.OpenTopic(topic, user); await _modService.Received(1).LogTopic(user, ModerationType.TopicOpen, topic, null); await _topicRepo.Received(1).OpenTopic(topic.TopicID); } [Fact] public async Task PinTopicThrowsWithNonMod() { var topic = new Topic { TopicID = 1 }; var user = GetUser(); var topicService = GetTopicService(); await Assert.ThrowsAsync(async () => await topicService.PinTopic(topic, user)); } [Fact] public async Task PinTopicPinsWithMod() { var topic = new Topic { TopicID = 1 }; var user = GetUser(); user.Roles.Add(PermanentRoles.Moderator); var topicService = GetTopicService(); await topicService.PinTopic(topic, user); await _modService.Received(1).LogTopic(user, ModerationType.TopicPin, topic, null); await _topicRepo.Received(1).PinTopic(topic.TopicID); } [Fact] public async Task UnpinTopicThrowsWithNonMod() { var topic = new Topic { TopicID = 1 }; var user = GetUser(); var topicService = GetTopicService(); await Assert.ThrowsAsync(async () => await topicService.UnpinTopic(topic, user)); } [Fact] public async Task UnpinTopicUnpinsWithMod() { var topic = new Topic { TopicID = 1 }; var user = GetUser(); user.Roles.Add(PermanentRoles.Moderator); var topicService = GetTopicService(); await topicService.UnpinTopic(topic, user); await _modService.Received(1).LogTopic(user, ModerationType.TopicUnpin, topic, null); await _topicRepo.Received(1).UnpinTopic(topic.TopicID); } [Fact] public async Task DeleteTopicThrowsWithNonMod() { var topic = new Topic { TopicID = 1 }; var user = GetUser(); var topicService = GetTopicService(); await Assert.ThrowsAsync(async () => await topicService.DeleteTopic(topic, user)); } [Fact] public async Task DeleteTopicDeletesWithMod() { var topic = new Topic { TopicID = 1 }; var user = GetUser(); user.Roles.Add(PermanentRoles.Moderator); var topicService = GetTopicService(); await topicService.DeleteTopic(topic, user); await _modService.Received(1).LogTopic(user, ModerationType.TopicDelete, topic, null); await _topicRepo.Received(1).DeleteTopic(topic.TopicID); } [Fact] public async Task DeleteTopicUpdatesCounts() { var topic = new Topic { TopicID = 1, ForumID = 123 }; var user = GetUser(); user.Roles.Add(PermanentRoles.Moderator); var topicService = GetTopicService(); var forum = new Forum { ForumID = topic.ForumID }; _forumService.Get(topic.ForumID).Returns(Task.FromResult(forum)); await topicService.DeleteTopic(topic, user); _forumService.Received(1).UpdateCounts(forum); } [Fact] public async Task DeleteTopicQueuesIndexRemoval() { var topic = new Topic { TopicID = 1, ForumID = 123 }; var user = GetUser(); user.Roles.Add(PermanentRoles.Moderator); var topicService = GetTopicService(); var forum = new Forum { ForumID = topic.ForumID }; _forumService.Get(topic.ForumID).Returns(Task.FromResult(forum)); SearchIndexPayload payload = null; await _searchIndexQueueRepo.Enqueue(Arg.Do(x => payload = x)); _tenantService.GetTenant().Returns("t"); await topicService.DeleteTopic(topic, user); Assert.Equal(topic.TopicID, payload.TopicID); Assert.Equal("t", payload.TenantID); Assert.True(payload.IsForRemoval); } [Fact] public async Task DeleteTopicUpdatesLast() { var topic = new Topic { TopicID = 1, ForumID = 123 }; var user = GetUser(); user.Roles.Add(PermanentRoles.Moderator); var topicService = GetTopicService(); var forum = new Forum { ForumID = topic.ForumID }; _forumService.Get(topic.ForumID).Returns(Task.FromResult(forum)); await topicService.DeleteTopic(topic, user); await _forumService.Received(1).UpdateLast(forum); } [Fact] public async Task DeleteTopicUpdatesReplyCount() { var topic = new Topic { TopicID = 1 }; var user = GetUser(); user.Roles.Add(PermanentRoles.Moderator); var topicService = GetTopicService(); _postRepo.GetReplyCount(topic.TopicID, false).Returns(Task.FromResult(42)); await topicService.DeleteTopic(topic, user); await _topicRepo.Received(1).UpdateReplyCount(topic.TopicID, 42); } [Fact] public async Task DeleteTopicDeletesWithStarter() { var user = GetUser(); var topic = new Topic { TopicID = 1, StartedByUserID = user.UserID }; var topicService = GetTopicService(); await topicService.DeleteTopic(topic, user); await _modService.Received(1).LogTopic(user, ModerationType.TopicDelete, topic, null); await _topicRepo.Received(1).DeleteTopic(topic.TopicID); } [Fact] public async Task UndeleteTopicThrowsWithNonMod() { var topic = new Topic { TopicID = 1 }; var user = GetUser(); var topicService = GetTopicService(); await Assert.ThrowsAsync(async () => await topicService.UndeleteTopic(topic, user)); } [Fact] public async Task UndeleteTopicUndeletesWithMod() { var topic = new Topic { TopicID = 1 }; var user = GetUser(); user.Roles.Add(PermanentRoles.Moderator); var topicService = GetTopicService(); await topicService.UndeleteTopic(topic, user); await _modService.Received(1).LogTopic(user, ModerationType.TopicUndelete, topic, null); await _topicRepo.Received(1).UndeleteTopic(topic.TopicID); } [Fact] public async Task UndeleteTopicUpdatesCounts() { var topic = new Topic { TopicID = 1, ForumID = 123 }; var user = GetUser(); user.Roles.Add(PermanentRoles.Moderator); var topicService = GetTopicService(); var forum = new Forum { ForumID = topic.ForumID }; _forumService.Get(topic.ForumID).Returns(Task.FromResult(forum)); await topicService.UndeleteTopic(topic, user); _forumService.Received(1).UpdateCounts(forum); } [Fact] public async Task UndeleteTopicUpdatesLast() { var topic = new Topic { TopicID = 1, ForumID = 123 }; var user = GetUser(); user.Roles.Add(PermanentRoles.Moderator); var topicService = GetTopicService(); var forum = new Forum { ForumID = topic.ForumID }; _forumService.Get(topic.ForumID).Returns(Task.FromResult(forum)); await topicService.UndeleteTopic(topic, user); await _forumService.Received(1).UpdateLast(forum); } [Fact] public async Task UndeleteQueuesReindex() { var topic = new Topic { TopicID = 1, ForumID = 123 }; var user = GetUser(); user.Roles.Add(PermanentRoles.Moderator); var topicService = GetTopicService(); var forum = new Forum { ForumID = topic.ForumID }; _forumService.Get(topic.ForumID).Returns(Task.FromResult(forum)); SearchIndexPayload payload = null; await _searchIndexQueueRepo.Enqueue(Arg.Do(x => payload = x)); _tenantService.GetTenant().Returns("t"); await topicService.UndeleteTopic(topic, user); Assert.Equal(topic.TopicID, payload.TopicID); Assert.Equal("t", payload.TenantID); Assert.False(payload.IsForRemoval); } [Fact] public async Task UndeleteTopicUpdatesReplyCount() { var topic = new Topic { TopicID = 1 }; var user = GetUser(); user.Roles.Add(PermanentRoles.Moderator); var topicService = GetTopicService(); _postRepo.GetReplyCount(topic.TopicID, false).Returns(Task.FromResult(42)); await topicService.UndeleteTopic(topic, user); await _topicRepo.Received(1).UpdateReplyCount(topic.TopicID, 42); } [Fact] public async Task UpdateTopicThrowsWithNonMod() { var topic = new Topic { TopicID = 1 }; var user = GetUser(); var topicService = GetTopicService(); await Assert.ThrowsAsync(async () => await topicService.UpdateTitleAndForum(topic, new Forum { ForumID = 2 }, "blah", user)); } [Fact] public async Task UpdateTopicUpdatesTitleWithMod() { var forum = new Forum { ForumID = 2 }; var topic = new Topic { TopicID = 1, ForumID = forum.ForumID }; var user = GetUser(); user.Roles.Add(PermanentRoles.Moderator); var topicService = GetTopicService(); _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(new Topic { TopicID = 1, ForumID = 2 })); _topicRepo.GetUrlNamesThatStartWith(Arg.Any()).Returns(Task.FromResult(new List())); await topicService.UpdateTitleAndForum(topic, forum, "new title", user); await _modService.Received(1).LogTopic(user, ModerationType.TopicRenamed, topic, forum, Arg.Any()); await _topicRepo.Received(1).UpdateTitleAndForum(topic.TopicID, forum.ForumID, "new title", "new-title"); } [Fact] public async Task UpdateTopicQueuesTopicForIndexingWithMod() { var forum = new Forum { ForumID = 2 }; var topic = new Topic { TopicID = 1, ForumID = forum.ForumID }; var user = GetUser(); user.Roles.Add(PermanentRoles.Moderator); var topicService = GetTopicService(); _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(new Topic { TopicID = 1, ForumID = 2 })); _topicRepo.GetUrlNamesThatStartWith(Arg.Any()).Returns(Task.FromResult(new List())); _tenantService.GetTenant().Returns(""); await topicService.UpdateTitleAndForum(topic, forum, "new title", user); await _searchIndexQueueRepo.Received().Enqueue(Arg.Any()); } [Fact] public async Task UpdateTopicMovesTopicWithMod() { var forum = new Forum { ForumID = 2 }; var topic = new Topic { TopicID = 1, ForumID = 7, Title = String.Empty }; var user = GetUser(); user.Roles.Add(PermanentRoles.Moderator); var topicService = GetTopicService(); _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(new Topic { TopicID = 1, ForumID = 3 })); _topicRepo.GetUrlNamesThatStartWith(Arg.Any()).Returns(Task.FromResult(new List())); await topicService.UpdateTitleAndForum(topic, forum, string.Empty, user); await _modService.Received(1).LogTopic(user, ModerationType.TopicMoved, topic, forum, Arg.Any()); await _topicRepo.Received(1).UpdateTitleAndForum(topic.TopicID, forum.ForumID, String.Empty, String.Empty); } [Fact] public async Task UpdateTopicWithNewTitleChangesUrlNameOnTopicParameter() { var forum = new Forum { ForumID = 2 }; var topic = new Topic { TopicID = 1, ForumID = forum.ForumID, UrlName = "old" }; var user = GetUser(); user.Roles.Add(PermanentRoles.Moderator); var topicService = GetTopicService(); _topicRepo.GetUrlNamesThatStartWith(Arg.Any()).Returns(Task.FromResult(new List())); _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(new Topic { TopicID = 1, ForumID = 2 })); await topicService.UpdateTitleAndForum(topic, forum, "new title", user); Assert.Equal("new-title", topic.UrlName); } [Fact] public async Task UpdateTopicMovesUpdatesCountAndLastOnOldForum() { var forum = new Forum { ForumID = 2 }; var oldForum = new Forum { ForumID = 3 }; var topic = new Topic { TopicID = 1, ForumID = 7, Title = String.Empty }; var user = GetUser(); user.Roles.Add(PermanentRoles.Moderator); var topicService = GetTopicService(); _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(new Topic { TopicID = 1, ForumID = oldForum.ForumID })); _forumService.Get(oldForum.ForumID).Returns(Task.FromResult(oldForum)); _topicRepo.GetUrlNamesThatStartWith(Arg.Any()).Returns(Task.FromResult(new List())); await topicService.UpdateTitleAndForum(topic, forum, String.Empty, user); _forumService.Received(1).UpdateCounts(forum); await _forumService.Received(1).UpdateLast(forum); } [Fact] public async Task UpdateTopicMovesUpdatesCountAndLastOnNewForum() { var forum = new Forum { ForumID = 2 }; var oldForum = new Forum { ForumID = 3 }; var topic = new Topic { TopicID = 1, ForumID = 7, Title = String.Empty }; var user = GetUser(); user.Roles.Add(PermanentRoles.Moderator); var topicService = GetTopicService(); _topicRepo.Get(topic.TopicID).Returns(Task.FromResult(new Topic { TopicID = 1, ForumID = oldForum.ForumID })); _forumService.Get(oldForum.ForumID).Returns(Task.FromResult(oldForum)); _topicRepo.GetUrlNamesThatStartWith(Arg.Any()).Returns(Task.FromResult(new List())); await topicService.UpdateTitleAndForum(topic, forum, String.Empty, user); _forumService.Received(1).UpdateCounts(oldForum); await _forumService.Received(1).UpdateLast(oldForum); } [Fact] public async Task UpdateLastSetsFieldsFromLastPost() { var topic = new Topic { TopicID = 456 }; var post = new Post { PostID = 123, TopicID = topic.TopicID, UserID = 789, Name = "Dude", PostTime = new DateTime(2000, 1, 3)}; var service = GetTopicService(); _postRepo.GetLastInTopic(post.TopicID).Returns(Task.FromResult(post)); await service.UpdateLast(topic); await _topicRepo.Received().UpdateLastTimeAndUser(topic.TopicID, post.UserID, post.Name, post.PostTime); } [Fact] public async Task HardDeleteThrowsIfUserNotAdmin() { var user = new User { UserID = 123, Roles = new List() }; var topic = new Topic { TopicID = 45 }; var service = GetTopicService(); await Assert.ThrowsAsync(async () => await service.HardDeleteTopic(topic, user)); } [Fact] public async Task HardDeleteCallsModerationService() { var user = new User { UserID = 123, Roles = new List { "Admin" } }; var topic = new Topic { TopicID = 45 }; var service = GetTopicService(); await service.HardDeleteTopic(topic, user); await _modService.Received().LogTopic(user, ModerationType.TopicDeletePermanently, topic, null); } [Fact] public async Task HardDeleteCallsSearchIndexRepo() { var user = new User { UserID = 123, Roles = new List { "Admin" } }; var topic = new Topic { TopicID = 45 }; var service = GetTopicService(); SearchIndexPayload payload = null; await _searchIndexQueueRepo.Enqueue(Arg.Do(x => payload = x)); _tenantService.GetTenant().Returns("t"); await service.HardDeleteTopic(topic, user); await _searchIndexQueueRepo.Received().Enqueue(Arg.Any()); Assert.Equal(topic.TopicID, payload.TopicID); Assert.Equal("t", payload.TenantID); Assert.True(payload.IsForRemoval); } [Fact] public async Task HardDeleteCallsTopiRepoToDeleteTopic() { var user = new User { UserID = 123, Roles = new List { "Admin" } }; var topic = new Topic { TopicID = 45 }; var service = GetTopicService(); await service.HardDeleteTopic(topic, user); await _topicRepo.Received().HardDeleteTopic(topic.TopicID); } [Fact] public async Task HardDeleteCallsForumServiceToUpdateLastAndCounts() { var user = new User { UserID = 123, Roles = new List { "Admin" } }; var topic = new Topic { TopicID = 45, ForumID = 67}; var forum = new Forum { ForumID = topic.ForumID }; var service = GetTopicService(); _forumService.Get(topic.ForumID).Returns(Task.FromResult(forum)); await service.HardDeleteTopic(topic, user); _forumService.Received().UpdateCounts(forum); await _forumService.Received().UpdateLast(forum); } [Fact] public async Task SetAnswerThrowsWhenUserNotTopicStarter() { var service = GetTopicService(); var user = new User { UserID = 123 }; var topic = new Topic { TopicID = 456, StartedByUserID = 789 }; await Assert.ThrowsAsync(async () => await service.SetAnswer(user, topic, new Post { PostID = 789 }, "", "")); } [Fact] public async Task SetAnswerThrowsIfPostIDOfAnswerDoesntExist() { var service = GetTopicService(); var user = new User { UserID = 123 }; var topic = new Topic { TopicID = 456, StartedByUserID = 123 }; _postRepo.Get(Arg.Any()).Returns((Post) null); await Assert.ThrowsAsync(async () => await service.SetAnswer(user, topic, new Post { PostID = 789 }, "", "")); } [Fact] public async Task SetAnswerThrowsIfPostIsNotPartOfTopic() { var service = GetTopicService(); var user = new User { UserID = 123 }; var topic = new Topic { TopicID = 456, StartedByUserID = 123 }; var post = new Post { PostID = 789, TopicID = 111 }; _postRepo.Get(post.PostID).Returns(Task.FromResult(post)); await Assert.ThrowsAsync(async () => await service.SetAnswer(user, topic, post, "", "")); } [Fact] public async Task SetAnswerCallsTopicRepoWithUpdatedValue() { var service = GetTopicService(); var user = new User { UserID = 123, Name = "Dude" }; var topic = new Topic { TopicID = 456, StartedByUserID = 123, Title = "the title" }; var post = new Post { PostID = 789, TopicID = topic.TopicID, UserID = 777 }; _postRepo.Get(post.PostID).Returns(Task.FromResult(post)); await service.SetAnswer(user, topic, post, "", ""); await _topicRepo.Received().UpdateAnswerPostID(topic.TopicID, post.PostID); await _notificationAdapter.Received().QuestionAnswer(user.Name, topic.Title, post.PostID, post.UserID); } [Fact] public async Task SetAnswerCallsEventPubWhenThereIsNoPreviousAnswerOnTheTopic() { var service = GetTopicService(); var user = new User { UserID = 123 }; var answerUser = new User { UserID = 777 }; var topic = new Topic { TopicID = 456, StartedByUserID = user.UserID, AnswerPostID = null}; var post = new Post { PostID = 789, TopicID = topic.TopicID, UserID = answerUser.UserID}; _postRepo.Get(post.PostID).Returns(Task.FromResult(post)); _userRepo.GetUser(answerUser.UserID).Returns(Task.FromResult(answerUser)); await service.SetAnswer(user, topic, post, "", ""); await _eventPublisher.Received().ProcessEvent(Arg.Any(), answerUser, EventDefinitionService.StaticEventIDs.QuestionAnswered, false); } [Fact] public async Task SetAnswerDoesNotCallEventPubWhenTheAnswerUserDoesNotExist() { var service = GetTopicService(); var user = new User { UserID = 123 }; var topic = new Topic { TopicID = 456, StartedByUserID = user.UserID, AnswerPostID = null }; var post = new Post { PostID = 789, TopicID = topic.TopicID, UserID = 777 }; _postRepo.Get(post.PostID).Returns(Task.FromResult(post)); _userRepo.GetUser(Arg.Any()).Returns((User)null); await service.SetAnswer(user, topic, post, "", ""); await _eventPublisher.DidNotReceive().ProcessEvent(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task SetAnswerDoesNotCallEventPubWhenTheTopicAlreadyHasAnAnswer() { var service = GetTopicService(); var user = new User { UserID = 123 }; var answerUser = new User { UserID = 777 }; var topic = new Topic { TopicID = 456, StartedByUserID = user.UserID, AnswerPostID = 666 }; var post = new Post { PostID = 789, TopicID = topic.TopicID, UserID = answerUser.UserID }; _postRepo.Get(post.PostID).Returns(Task.FromResult(post)); _userRepo.GetUser(answerUser.UserID).Returns(Task.FromResult(answerUser)); await service.SetAnswer(user, topic, post, "", ""); await _eventPublisher.DidNotReceive().ProcessEvent(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task SetAnswerDoesNotCallEventPubWhenTopicUserIDIsSameAsAnswerUserID() { var service = GetTopicService(); var user = new User { UserID = 123 }; var topic = new Topic { TopicID = 456, StartedByUserID = user.UserID, AnswerPostID = null }; var post = new Post { PostID = 789, TopicID = topic.TopicID, UserID = user.UserID }; _postRepo.Get(post.PostID).Returns(Task.FromResult(post)); _userRepo.GetUser(user.UserID).Returns(Task.FromResult(user)); await service.SetAnswer(user, topic, post, "", ""); await _eventPublisher.DidNotReceive().ProcessEvent(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task CloseAgedTopicsDoesNothingWhenSettingIsOff() { var service = GetTopicService(); _settingsManager.Current.IsClosingAgedTopics.Returns(false); await service.CloseAgedTopics(); await _topicRepo.DidNotReceive().CloseTopicsOlderThan(Arg.Any()); } [Fact] public async Task CloseAgedTopicsCallsRepoWhenSettingIsOn() { var service = GetTopicService(); _settingsManager.Current.IsClosingAgedTopics.Returns(true); _topicRepo.CloseTopicsOlderThan(Arg.Any()).Returns(new List()); await service.CloseAgedTopics(); await _topicRepo.Received().CloseTopicsOlderThan(Arg.Any()); } [Fact] public async Task CloseAgedTopicsLogsTopicModeration() { var service = GetTopicService(); _settingsManager.Current.IsClosingAgedTopics.Returns(true); _topicRepo.CloseTopicsOlderThan(Arg.Any()).Returns(new List{1,2,3}); await service.CloseAgedTopics(); await _topicRepo.Received().CloseTopicsOlderThan(Arg.Any()); await _modService.Received().LogTopic(ModerationType.TopicCloseAuto, 1); await _modService.Received().LogTopic(ModerationType.TopicCloseAuto, 2); await _modService.Received().LogTopic(ModerationType.TopicCloseAuto, 3); } } ================================================ FILE: src/PopForums.Test/Services/TopicViewLogServiceTests.cs ================================================ namespace PopForums.Test.Services; public class TopicViewLogServiceTests { private TopicViewLogService GetService() { _config = Substitute.For(); _topicViewLogRepo = Substitute.For(); return new TopicViewLogService(_config, _topicViewLogRepo); } private IConfig _config; private ITopicViewLogRepository _topicViewLogRepo; [Fact] public async Task LogViewDoesNotCallRepoWhenConfigIsFalse() { var service = GetService(); _config.LogTopicViews.Returns(false); await service.LogView(123, 456); await _topicViewLogRepo.DidNotReceive().Log(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task LogViewDoesCallsRepoWhenConfigIsTrue() { var service = GetService(); _config.LogTopicViews.Returns(true); await service.LogView(123, 456); await _topicViewLogRepo.Received().Log(123, 456, Arg.Any()); } } ================================================ FILE: src/PopForums.Test/Services/UserEmailReconcilerTests.cs ================================================ namespace PopForums.Test.Services; public class UserEmailReconcilerTests { private IUserRepository _userRepo; private UserEmailReconciler GetService() { _userRepo = Substitute.For(); return new UserEmailReconciler(_userRepo); } public class GetUniqueEmail : UserEmailReconcilerTests { [Fact] public async Task UnmatchedReturnsSameEmail() { var service = GetService(); var email = "a@b.com"; _userRepo.GetUserByEmail(email).Returns((User)null); var result = await service.GetUniqueEmail(email, "12345"); Assert.Equal(email, result); } [Fact] public async Task MatchedReturnsUniqueEmail() { var service = GetService(); var email = "a@b.com"; var user = new User { Email = email }; _userRepo.GetUserByEmail(email).Returns(Task.FromResult(user)); var result = await service.GetUniqueEmail(email, "12345"); Assert.Equal("a-at-b.com@12345.example.com", result); } } } ================================================ FILE: src/PopForums.Test/Services/UserNameReconcilerTests.cs ================================================ namespace PopForums.Test.Services; public class UserNameReconcilerTests { private IUserRepository _userRepo; private UserNameReconciler GetService() { _userRepo = Substitute.For(); return new UserNameReconciler(_userRepo); } public class GetUniqueNameForNewUser : UserNameReconcilerTests { [Fact] public async Task NoMatchesReturnsSameName() { var name = "Jeff P"; var service = GetService(); _userRepo.GetUserNamesThatStartWith(name).Returns(new string[]{}); var result = await service.GetUniqueNameForUser(name); Assert.Equal(name, result); } [Fact] public async Task OneMatchesReturnsAppendedName() { var name = "Jeff P"; var service = GetService(); _userRepo.GetUserNamesThatStartWith(name).Returns(new []{ name }); var result = await service.GetUniqueNameForUser(name); Assert.Equal("Jeff P-2", result); } [Fact] public async Task ThreeMatchesReturnsAppendedName() { var name = "Jeff P"; var service = GetService(); _userRepo.GetUserNamesThatStartWith(name).Returns(new []{ "Jeff P", "Jeff P-2", "Jeff P-3" }); var result = await service.GetUniqueNameForUser(name); Assert.Equal("Jeff P-4", result); } [Fact] public async Task ThreeMatchesWithOneExtraReturnsAppendedName() { var name = "Jeff P"; var service = GetService(); _userRepo.GetUserNamesThatStartWith(name).Returns(new []{ "Jeff P", "Jeff Peterson", "Jeff P-2" }); var result = await service.GetUniqueNameForUser(name); Assert.Equal("Jeff P-3", result); } } } ================================================ FILE: src/PopForums.Test/Services/UserServiceTests.cs ================================================ namespace PopForums.Test.Services; public class UserServiceTests { private IUserRepository _mockUserRepo; private IRoleRepository _mockRoleRepo; private IProfileRepository _mockProfileRepo; private ISettingsManager _mockSettingsManager; private IUserAvatarRepository _mockUserAvatarRepo; private IUserImageRepository _mockUserImageRepo; private ISecurityLogService _mockSecurityLogService; private ITextParsingService _mockTextParser; private IBanRepository _mockBanRepo; private IForgotPasswordMailer _mockForgotMailer; private IImageService _mockImageService; private IConfig _config; private UserService GetMockedUserService() { _mockUserRepo = Substitute.For(); _mockRoleRepo = Substitute.For(); _mockProfileRepo = Substitute.For(); _mockSettingsManager = Substitute.For(); _mockUserAvatarRepo = Substitute.For(); _mockUserImageRepo = Substitute.For(); _mockSecurityLogService = Substitute.For(); _mockTextParser = Substitute.For(); _mockBanRepo = Substitute.For(); _mockForgotMailer = Substitute.For(); _mockImageService = Substitute.For(); _config = Substitute.For(); _mockRoleRepo.GetUserRoles(Arg.Any()).Returns(Task.FromResult(new List())); return new UserService(_mockUserRepo, _mockRoleRepo, _mockProfileRepo, _mockSettingsManager, _mockUserAvatarRepo, _mockUserImageRepo, _mockSecurityLogService, _mockTextParser, _mockBanRepo, _mockForgotMailer, _mockImageService, _config); } [Fact] public async Task SetPassword() { var userService = GetMockedUserService(); var user = GetDummyUser("jeff", "a@b.com"); var salt = Guid.NewGuid(); await _mockUserRepo.SetHashedPassword(user, Arg.Any(), Arg.Do(x => salt = x)); await userService.SetPassword(user, "fred", String.Empty, user); var hashedPassword = "fred".GetSHA256Hash(salt); await _mockUserRepo.Received().SetHashedPassword(user, hashedPassword, salt); } [Fact] public async Task CheckPassword() { var userService = GetMockedUserService(); _mockUserRepo.GetHashedPasswordByEmail(string.Empty).Returns(Task.FromResult(Tuple.Create("0M/C5TGbgs3HGjOHPoJsk9fuETY/iskcT6Oiz80ihuU=", (Guid?)null))); var result = await userService.CheckPassword(String.Empty, "fred"); Assert.True(result.Item1); } [Fact] public async Task CheckPasswordFail() { var userService = GetMockedUserService(); _mockUserRepo.GetHashedPasswordByEmail(string.Empty).Returns(Task.FromResult(Tuple.Create("VwqQv7+MfqtdxdTiaDLVsQ==", (Guid?)null))); var result = await userService.CheckPassword(String.Empty, "fsdfsdfsdfsdf"); Assert.False(result.Item1); } [Fact] public async Task CheckPasswordHasSalt() { var userService = GetMockedUserService(); Guid? salt = Guid.NewGuid(); var hashedPassword = "fred".GetSHA256Hash(salt.Value); _mockUserRepo.GetHashedPasswordByEmail(string.Empty).Returns(Task.FromResult(Tuple.Create(hashedPassword, salt))); var result = await userService.CheckPassword(String.Empty, "fred"); Assert.True(result.Item1); } [Fact] public async Task CheckPasswordHasSaltFail() { var userService = GetMockedUserService(); Guid? salt = Guid.NewGuid(); var hashedPassword = "fred".GetSHA256Hash(salt.Value); _mockUserRepo.GetHashedPasswordByEmail(string.Empty).Returns(Task.FromResult(Tuple.Create(hashedPassword, salt))); var result = await userService.CheckPassword(String.Empty, "dsfsdfsdfsdf"); Assert.False(result.Item1); } [Fact] public async Task CheckPasswordPassesWithoutSaltOnMD5Fallback() { var userService = GetMockedUserService(); Guid? salt = null; var hashedPassword = "fred".GetMD5Hash(); _mockUserRepo.GetHashedPasswordByEmail(string.Empty).Returns(Task.FromResult(Tuple.Create(hashedPassword, salt))); var result = await userService.CheckPassword(String.Empty, "fred"); Assert.True(result.Item1); } [Fact] public async Task CheckPasswordPassesWithSaltOnMD5Fallback() { var userService = GetMockedUserService(); Guid? salt = Guid.NewGuid(); var hashedPassword = "fred".GetMD5Hash(salt.Value); _mockUserRepo.GetHashedPasswordByEmail(string.Empty).Returns(Task.FromResult(Tuple.Create(hashedPassword, salt))); var result = await userService.CheckPassword(String.Empty, "fred"); Assert.True(result.Item1); } [Fact] public async Task CheckPasswordFailsOnMD5FallbackNoMatch() { var userService = GetMockedUserService(); Guid? salt = Guid.NewGuid(); var hashedPassword = "fred".GetMD5Hash(salt.Value); _mockUserRepo.GetHashedPasswordByEmail(string.Empty).Returns(Task.FromResult(Tuple.Create(hashedPassword, salt))); var result = await userService.CheckPassword(String.Empty, "blah"); Assert.False(result.Item1); } [Fact] public async Task CheckPasswordFailsMD5FallbackDoesNotCallUpdate() { var userService = GetMockedUserService(); Guid? salt = Guid.NewGuid(); var email = "a@b.com"; var user = new User { Email = email }; var hashedPassword = "fred".GetMD5Hash(salt.Value); _mockUserRepo.GetHashedPasswordByEmail(email).Returns(Task.FromResult(Tuple.Create(hashedPassword, salt))); _mockUserRepo.GetUserByEmail(email).Returns(Task.FromResult(user)); var result = await userService.CheckPassword(email, "blah"); Assert.False(result.Item1); await _mockUserRepo.DidNotReceive().SetHashedPassword(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task CheckPasswordPassesWithSaltOnMD5FallbackCallsUpdate() { var userService = GetMockedUserService(); Guid? salt = Guid.NewGuid(); var email = "a@b.com"; var user = new User {Email = email}; var hashedPassword = "fred".GetMD5Hash(salt.Value); _mockUserRepo.GetHashedPasswordByEmail(email).Returns(Task.FromResult(Tuple.Create(hashedPassword, salt))); _mockUserRepo.GetUserByEmail(email).Returns(Task.FromResult(user)); var result = await userService.CheckPassword(email, "fred"); Assert.True(result.Item1); await _mockUserRepo.Received().SetHashedPassword(user, Arg.Any(), Arg.Any()); } [Fact] public async Task GetUser() { const int id = 1; const string name = "Jeff"; const string email = "a@b.com"; var roles = new List {"blah", PermanentRoles.Admin}; var userService = GetMockedUserService(); var dummyUser = GetDummyUser(name, email); _mockUserRepo.GetUser(id).Returns(Task.FromResult(dummyUser)); _mockRoleRepo.GetUserRoles(id).Returns(Task.FromResult(roles)); var user = await userService.GetUser(id); Assert.Same(dummyUser, user); } [Fact] public async Task GetUserFail() { const int id = 1; var userService = GetMockedUserService(); _mockUserRepo.GetUser(Arg.Is(i => i != 1)).Returns(Task.FromResult(GetDummyUser("", ""))); _mockUserRepo.GetUser(Arg.Is(i => i == 1)).Returns((User)null); var user = await userService.GetUser(id); Assert.Null(user); } [Fact] public async Task GetUserByName() { const string name = "Jeff"; const string email = "a@b.com"; var roles = new List { "blah", PermanentRoles.Admin }; var dummyUser = GetDummyUser(name, email); var userService = GetMockedUserService(); _mockUserRepo.GetUserByName(name.ToLower()).Returns(Task.FromResult(dummyUser)); _mockRoleRepo.GetUserRoles(dummyUser.UserID).Returns(Task.FromResult(roles)); var user = await userService.GetUserByName(name); Assert.Same(dummyUser, user); } [Fact] public async Task GetUserByNameFail() { const string name = "Jeff"; var userService = GetMockedUserService(); _mockUserRepo.GetUserByName(Arg.Is(i => i != name.ToLower())).Returns(Task.FromResult(GetDummyUser(name, ""))); _mockUserRepo.GetUserByName(Arg.Is(i => i == name.ToLower())).Returns((User)null); var user = await userService.GetUserByName(name); Assert.Null(user); } [Fact] public async Task GetUserByNameReturnsNullWithNullOrEmptyName() { var userService = GetMockedUserService(); Assert.Null(await userService.GetUserByName("")); Assert.Null(await userService.GetUserByName(null)); } [Fact] public async Task GetUserByEmail() { const string name = "Jeff"; const string email = "a@b.com"; var roles = new List { "blah", PermanentRoles.Admin }; var dummyUser = GetDummyUser(name, email); var userService = GetMockedUserService(); _mockUserRepo.GetUserByEmail(email).Returns(Task.FromResult(dummyUser)); _mockRoleRepo.GetUserRoles(dummyUser.UserID).Returns(Task.FromResult(roles)); var user = await userService.GetUserByEmail(email); Assert.Same(dummyUser, user); } [Fact] public async Task GetUserByEmailFail() { const string email = "a@b.com"; var userManager = GetMockedUserService(); _mockUserRepo.GetUserByEmail(Arg.Is(i => i != email)).Returns(Task.FromResult(GetDummyUser("", email))); _mockUserRepo.GetUserByEmail(Arg.Is(i => i == email)).Returns((User)null); var user = await userManager.GetUserByEmail(email); Assert.Null(user); } public static User GetDummyUser(string name, string email) { return new User { UserID = 1, Name = name, Email = email, IsApproved = true, AuthorizationKey = new Guid()}; } [Fact] public async Task NameIsInUse() { const string name = "jeff"; var userService = GetMockedUserService(); _mockUserRepo.GetUserByName(Arg.Is(name)).Returns(Task.FromResult(GetDummyUser(name, "a@b.com"))); Assert.True(await userService.IsNameInUse(name)); await _mockUserRepo.Received(1).GetUserByName(name); Assert.False(await userService.IsNameInUse("notjeff")); Assert.True(await userService.IsNameInUse(name.ToUpper())); } [Fact] public async Task EmailIsInUse() { const string email = "a@b.com"; var userManager = GetMockedUserService(); _mockUserRepo.GetUserByEmail(Arg.Is(email)).Returns(Task.FromResult(GetDummyUser("jeff", email))); Assert.True(await userManager.IsEmailInUse(email)); await _mockUserRepo.Received(1).GetUserByEmail(email); Assert.False(await userManager.IsEmailInUse("nota@b.com")); Assert.True(await userManager.IsEmailInUse(email.ToUpper())); } [Fact] public async Task EmailInUserByAnotherTrue() { var userService = GetMockedUserService(); var user = GetDummyUser("jeff", "a@b.com"); _mockUserRepo.GetUserByEmail("c@d.com").Returns(Task.FromResult(new User { UserID = 123 })); var result = await userService.IsEmailInUseByDifferentUser(user, "c@d.com"); await _mockUserRepo.Received().GetUserByEmail("c@d.com"); Assert.True(result); } [Fact] public async Task EmailInUserByAnotherFalseBecauseSameUser() { var userService = GetMockedUserService(); var user = GetDummyUser("jeff", "a@b.com"); _mockUserRepo.GetUserByEmail("a@b.com").Returns(Task.FromResult(user)); var result = await userService.IsEmailInUseByDifferentUser(user, "a@b.com"); await _mockUserRepo.Received().GetUserByEmail("a@b.com"); Assert.False(result); } [Fact] public async Task EmailInUserByAnotherFalseBecauseNoUser() { var userService = GetMockedUserService(); var user = GetDummyUser("jeff", "a@b.com"); _mockUserRepo.GetUserByEmail("c@d.com").Returns((User)null); var result = await userService.IsEmailInUseByDifferentUser(user, "c@d.com"); await _mockUserRepo.Received().GetUserByEmail("c@d.com"); Assert.False(result); } [Fact] public async Task CreateUser() { const string name = "jeff"; const string nameCensor = "jeffcensor"; const string email = "a@b.com"; const string password = "fred"; const string ip = "127.0.0.1"; var userService = GetMockedUserService(); var dummyUser = GetDummyUser(nameCensor, email); _mockUserRepo.CreateUser(nameCensor, email, Arg.Any(), true, Arg.Any(), Arg.Any(), Arg.Any()).Returns(Task.FromResult(dummyUser)); _mockTextParser.Censor(name).Returns(nameCensor); var user = await userService.CreateUser(name, email, password, true, ip); Assert.Equal(dummyUser.Name, user.Name); Assert.Equal(dummyUser.Email, user.Email); _mockTextParser.Received().Censor(name); await _mockUserRepo.Received().CreateUser(nameCensor, email, Arg.Any(), true, Arg.Any(), Arg.Any(), Arg.Any()); await _mockSecurityLogService.Received().CreateLogEntry(null, user, ip, String.Empty, SecurityLogType.UserCreated); } [Fact] public async Task CreateUserFromSignup() { const string name = "jeff"; const string nameCensor = "jeffcensor"; const string email = "a@b.com"; const string password = "fred"; const string ip = "127.0.0.1"; var userManager = GetMockedUserService(); var dummyUser = GetDummyUser(nameCensor, email); _mockUserRepo.CreateUser(nameCensor, email, Arg.Any(), true, Arg.Any(), Arg.Any(), Arg.Any()).Returns(Task.FromResult(dummyUser)); _mockTextParser.Censor(name).Returns(nameCensor); var settings = new Settings(); _mockSettingsManager.Current.Returns(settings); var signupData = new SignupData {Email = email, Name = name, Password = password}; var user = await userManager.CreateUserWithProfile(signupData, ip); await _mockUserRepo.Received().CreateUser(nameCensor, email, Arg.Any(), true, Arg.Any(), Arg.Any(), Arg.Any()); await _mockSecurityLogService.Received().CreateLogEntry(null, user, ip, String.Empty, SecurityLogType.UserCreated); } [Fact] public async Task CreateInvalidEmail() { var userService = GetMockedUserService(); _mockTextParser.Censor(Arg.Any()).Returns("blah"); await Assert.ThrowsAsync(async () => await userService.CreateUser("", "a b@oihfwe", "", true, "")); } [Fact] public async Task CreateUsedName() { const string usedName = "jeff"; const string email = "a@b.com"; var userService = GetMockedUserService(); _mockTextParser.Censor("jeff").Returns("jeff"); _mockTextParser.Censor("anynamejeff").Returns("anynamejeff"); _mockUserRepo.GetUserByName(Arg.Is(usedName)).Returns(Task.FromResult(GetDummyUser(usedName, email))); await Assert.ThrowsAsync(async () => await userService.CreateUser(usedName, email, "", true, "")); await Assert.ThrowsAsync(async () => await userService.CreateUser(usedName.ToUpper(), email, "", true, "")); } [Fact] public async Task CreateNameNull() { var userManager = GetMockedUserService(); await Assert.ThrowsAsync(async () => await userManager.CreateUser(null, "a@b.com", "", true, "")); } [Fact] public async Task CreateNameEmpty() { var userManager = GetMockedUserService(); await Assert.ThrowsAsync(async () => await userManager.CreateUser(String.Empty, "a@b.com", "", true, "")); } [Fact] public async Task CreateUsedEmail() { const string usedEmail = "a@b.com"; var userService = GetMockedUserService(); _mockTextParser.Censor(Arg.Any()).Returns("blah"); _mockUserRepo.GetUserByEmail(Arg.Is(usedEmail)).Returns(Task.FromResult(GetDummyUser("jeff", usedEmail))); await Assert.ThrowsAsync(async () => await userService.CreateUser("", usedEmail, "", true, "")); } [Fact] public async Task CreateEmailBanned() { const string bannedEmail = "a@b.com"; var userService = GetMockedUserService(); _mockTextParser.Censor(Arg.Any()).Returns("blah"); _mockBanRepo.EmailIsBanned(bannedEmail).Returns(Task.FromResult(true)); await Assert.ThrowsAsync(async () => await userService.CreateUser("name", bannedEmail, "", true, "")); } [Fact] public async Task CreateIPBanned() { const string bannedIP = "1.2.3.4"; var userManager = GetMockedUserService(); _mockTextParser.Censor(Arg.Any()).Returns("blah"); _mockBanRepo.IPIsBanned(bannedIP).Returns(Task.FromResult(true)); await Assert.ThrowsAsync(async () => await userManager.CreateUser("", "a@b.com", "", true, bannedIP)); } [Fact] public async Task UpdateLastActivityDate() { var userManager = GetMockedUserService(); var user = UserTest.GetTestUser(); await userManager.UpdateLastActivityDate(user); await _mockUserRepo.Received().UpdateLastActivityDate(user, Arg.Any()); } [Fact] public async Task ChangeEmailSuccess() { const string oldName = "Jeff"; const string oldEmail = "a@b.com"; const string newEmail = "c@d.com"; var userManager = GetMockedUserService(); _mockUserRepo.GetUserByEmail(oldEmail).Returns(Task.FromResult(GetDummyUser(oldName, oldEmail))); _mockUserRepo.GetUserByEmail(newEmail).Returns((User)null); _mockSettingsManager.Current.IsNewUserApproved.Returns(false); var targetUser = GetDummyUser(oldName, oldEmail); var user = new User { UserID = 34243 }; await userManager.ChangeEmail(targetUser, newEmail, user, "123"); await _mockUserRepo.Received(1).ChangeEmail(targetUser, newEmail); await _mockSecurityLogService.Received().CreateLogEntry(user, targetUser, "123", "Old: a@b.com, New: c@d.com", SecurityLogType.EmailChange); } [Fact] public async Task ChangeEmailAlreadyInUse() { const string oldName = "Jeff"; const string oldEmail = "a@b.com"; const string newEmail = "c@d.com"; var userService = GetMockedUserService(); _mockUserRepo.GetUserByEmail(oldEmail).Returns(Task.FromResult(GetDummyUser(oldName, oldEmail))); _mockUserRepo.GetUserByEmail(newEmail).Returns(Task.FromResult(GetDummyUser("Diana", newEmail))); _mockSettingsManager.Current.IsNewUserApproved.Returns(true); var user = GetDummyUser(oldName, oldEmail); await Assert.ThrowsAsync(() => userService.ChangeEmail(user, newEmail, new User(), string.Empty)); await _mockUserRepo.DidNotReceive().ChangeEmail(Arg.Any(), Arg.Any()); } [Fact] public async Task ChangeEmailBad() { const string badEmail = "a b @ c"; var userManager = GetMockedUserService(); _mockSettingsManager.Current.IsNewUserApproved.Returns(true); var user = GetDummyUser("", ""); await Assert.ThrowsAsync(() => userManager.ChangeEmail(user, badEmail, new User(), String.Empty)); await _mockUserRepo.DidNotReceive().ChangeEmail(Arg.Any(), Arg.Any()); } [Fact] public async Task ChangeEmailMapsIsApprovedFromSettingsToUserRepoCall() { const string oldName = "Jeff"; const string oldEmail = "a@b.com"; const string newEmail = "c@d.com"; var userManager = GetMockedUserService(); _mockUserRepo.GetUserByEmail(oldEmail).Returns(Task.FromResult(GetDummyUser(oldName, oldEmail))); _mockUserRepo.GetUserByEmail(newEmail).Returns((User)null); _mockSettingsManager.Current.IsNewUserApproved.Returns(true); var targetUser = GetDummyUser(oldName, oldEmail); var user = new User { UserID = 34243 }; await userManager.ChangeEmail(targetUser, newEmail, user, "123"); await _mockUserRepo.Received().UpdateIsApproved(targetUser, true); } [Fact] public async Task ChangeNameSuccess() { const string oldName = "Jeff"; const string oldEmail = "a@b.com"; const string newName = "Diana"; var userManager = GetMockedUserService(); _mockUserRepo.GetUserByName(oldName).Returns(Task.FromResult(GetDummyUser(oldName, oldEmail))); _mockUserRepo.GetUserByName(newName).Returns((User)null); var targetUser = GetDummyUser(oldName, oldEmail); var user = new User { UserID = 1234531 }; await userManager.ChangeName(targetUser, newName, user, "123"); await _mockUserRepo.Received().ChangeName(targetUser, newName); await _mockSecurityLogService.Received().CreateLogEntry(user, targetUser, "123", "Old: Jeff, New: Diana", SecurityLogType.NameChange); } [Fact] public async Task ChangeNameFailUsed() { const string oldName = "Jeff"; const string oldEmail = "a@b.com"; const string newName = "Diana"; var userService = GetMockedUserService(); _mockUserRepo.GetUserByName(oldName.ToLower()).Returns(Task.FromResult(GetDummyUser(oldName, oldEmail))); _mockUserRepo.GetUserByName(newName.ToLower()).Returns(Task.FromResult(GetDummyUser(newName, oldEmail))); var user = GetDummyUser(oldName, oldEmail); await Assert.ThrowsAsync(() => userService.ChangeName(user, newName, new User(), string.Empty)); await _mockUserRepo.DidNotReceive().ChangeName(Arg.Any(), Arg.Any()); } [Fact] public async Task ChangeNameNull() { var userService = GetMockedUserService(); var user = GetDummyUser("Jeff", "a@b.com"); await Assert.ThrowsAsync(() => userService.ChangeName(user, null, new User(), string.Empty)); await _mockUserRepo.DidNotReceive().ChangeName(Arg.Any(), Arg.Any()); } [Fact] public async Task ChangeNameEmpty() { var userService = GetMockedUserService(); var user = GetDummyUser("Jeff", "a@b.com"); await Assert.ThrowsAsync(() => userService.ChangeName(user, String.Empty, new User(), string.Empty)); await _mockUserRepo.DidNotReceive().ChangeName(Arg.Any(), Arg.Any()); } [Fact] public async Task Logout() { var userService = GetMockedUserService(); var user = UserTest.GetTestUser(); await userService.Logout(user, "123"); await _mockSecurityLogService.Received().CreateLogEntry(null, user, "123", String.Empty, SecurityLogType.Logout); } [Fact] public async Task LoginSuccess() { const string email = "a@b.com"; const string password = "fred"; const string ip = "1.1.1.1"; var user = UserTest.GetTestUser(); var userService = GetMockedUserService(); Guid? salt = Guid.NewGuid(); var saltedHash = password.GetSHA256Hash(salt.Value); _mockUserRepo.GetHashedPasswordByEmail(email).Returns(Task.FromResult(Tuple.Create(saltedHash, salt))); _mockUserRepo.GetUserByEmail(email).Returns(Task.FromResult(user)); var (result, _) = await userService.Login(email, password, ip); Assert.True(result); await _mockUserRepo.Received().UpdateLastLoginDate(user, Arg.Any()); await _mockSecurityLogService.Received().CreateLogEntry(null, user, ip, "", SecurityLogType.Login); await _mockUserRepo.DidNotReceive().SetHashedPassword(user, Arg.Any(), Arg.Any()); } [Fact] public async Task LoginSuccessNoSalt() { const string email = "a@b.com"; const string password = "fred"; const string ip = "1.1.1.1"; var user = UserTest.GetTestUser(); var userService = GetMockedUserService(); Guid? salt = Guid.NewGuid(); Guid? nosalt = null; _mockUserRepo.GetHashedPasswordByEmail(email).Returns(Tuple.Create(password.GetSHA256Hash(), nosalt)); _mockUserRepo.GetUserByEmail(email).Returns(Task.FromResult(user)); await _mockUserRepo.SetHashedPassword(user, Arg.Any(), Arg.Do(x => salt = x)); var (result, _) = await userService.Login(email, password, ip); Assert.True(result); await _mockUserRepo.Received().UpdateLastLoginDate(user, Arg.Any()); await _mockSecurityLogService.Received().CreateLogEntry(null, user, ip, "", SecurityLogType.Login); var saltyPassword = password.GetSHA256Hash(salt.Value); await _mockUserRepo.Received().SetHashedPassword(user, saltyPassword, salt.Value); } [Fact] public async Task LoginFail() { const string email = "a@b.com"; const string password = "fred"; const string ip = "1.1.1.1"; var userService = GetMockedUserService(); Guid? salt = null; _mockUserRepo.GetHashedPasswordByEmail(Arg.Any()).Returns(Task.FromResult(Tuple.Create("1234", salt))); var (result, userOut) = await userService.Login(email, password, ip); Assert.False(result); await _mockSecurityLogService.Received().CreateLogEntry((User)null, null, ip, "E-mail attempted: " + email, SecurityLogType.FailedLogin); await _mockUserRepo.DidNotReceive().SetHashedPassword(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] public async Task LoginWithUser() { var user = UserTest.GetTestUser(); var service = GetMockedUserService(); const string ip = "1.1.1.1"; await service.Login(user, ip); await _mockUserRepo.Received().UpdateLastLoginDate(user, Arg.Any()); await _mockSecurityLogService.Received().CreateLogEntry(null, user, ip, String.Empty, SecurityLogType.Login); } [Fact] public async Task LoginWithUserPersistCookie() { var user = UserTest.GetTestUser(); var service = GetMockedUserService(); const string ip = "1.1.1.1"; await service.Login(user, ip); await _mockUserRepo.Received().UpdateLastLoginDate(user, Arg.Any()); await _mockSecurityLogService.Received().CreateLogEntry(null, user, ip, String.Empty, SecurityLogType.Login); } [Fact] public async Task GetAllRoles() { var userService = GetMockedUserService(); var list = new List(); _mockRoleRepo.GetAllRoles().Returns(Task.FromResult(list)); var result = await userService.GetAllRoles(); await _mockRoleRepo.Received().GetAllRoles(); Assert.Same(list, result); } [Fact] public async Task CreateRole() { var userService = GetMockedUserService(); const string role = "blah"; await userService.CreateRole(role, new User(), string.Empty); await _mockRoleRepo.Received().CreateRole(role); await _mockSecurityLogService.Received().CreateLogEntry(Arg.Any(), Arg.Any(), string.Empty, "Role: blah", SecurityLogType.RoleCreated); } [Fact] public async Task DeleteRole() { var userService = GetMockedUserService(); const string role = "blah"; await userService.DeleteRole(role, default, default); await _mockRoleRepo.Received().DeleteRole(role); await _mockSecurityLogService.Received().CreateLogEntry(Arg.Any(), Arg.Any(), Arg.Any(), "Role: blah", SecurityLogType.RoleDeleted); } [Fact] public async Task DeleteRoleThrowsOnAdminOrMod() { var userService = GetMockedUserService(); await Assert.ThrowsAsync(() => userService.DeleteRole("Admin", default, default)); await Assert.ThrowsAsync(() => userService.DeleteRole("Moderator", default, default)); await Assert.ThrowsAsync(() => userService.DeleteRole("admin", default, default)); await Assert.ThrowsAsync(() => userService.DeleteRole("moderator", default, default)); await _mockRoleRepo.DidNotReceive().DeleteRole(Arg.Any()); } [Fact] public async Task UpdateIsApproved() { var userService = GetMockedUserService(); var targetUser = GetDummyUser("Jeff", "a@b.com"); var user = new User { UserID = 97 }; await userService.UpdateIsApproved(targetUser, true, user, "123"); await _mockUserRepo.Received().UpdateIsApproved(targetUser, true); await _mockSecurityLogService.Received().CreateLogEntry(user, targetUser, Arg.Any(), String.Empty, SecurityLogType.IsApproved); } [Fact] public async Task UpdateIsApprovedFalse() { var userService = GetMockedUserService(); var targetUser = GetDummyUser("Jeff", "a@b.com"); var user = new User { UserID = 97 }; await userService.UpdateIsApproved(targetUser, false, user, "123"); await _mockUserRepo.Received().UpdateIsApproved(targetUser, false); await _mockSecurityLogService.Received().CreateLogEntry(user, targetUser, Arg.Any(), String.Empty, SecurityLogType.IsNotApproved); } [Fact] public async Task UpdateAuthKey() { var userService = GetMockedUserService(); var user = GetDummyUser("Jeff", "a@b.com"); var key = Guid.NewGuid(); await userService.UpdateAuthorizationKey(user, key); await _mockUserRepo.Received().UpdateAuthorizationKey(user, key); } [Fact] public async Task VerifyUserByAuthKey() { var key = Guid.NewGuid(); const string name = "Jeff"; const string email = "a@b.com"; var dummyUser = GetDummyUser(name, email); dummyUser.AuthorizationKey = key; var userManager = GetMockedUserService(); _mockUserRepo.GetUserByAuthorizationKey(key).Returns(Task.FromResult(dummyUser)); var user = await userManager.VerifyAuthorizationCode(dummyUser.AuthorizationKey, "123"); Assert.Same(dummyUser, user); await _mockUserRepo.Received().UpdateIsApproved(dummyUser, true); } [Fact] public async Task VerifyUserByAuthKeyFail() { var service = GetMockedUserService(); _mockUserRepo.GetUserByAuthorizationKey(Arg.Any()).Returns((User)null); var user = await service.VerifyAuthorizationCode(Guid.NewGuid(), "123"); Assert.Null(user); await _mockUserRepo.DidNotReceive().UpdateIsApproved(Arg.Any(), true); } [Fact] public async Task SearchByEmail() { var service = GetMockedUserService(); var list = new List(); _mockUserRepo.SearchByEmail("blah").Returns(Task.FromResult(list)); var result = await service.SearchByEmail("blah"); Assert.Same(list, result); await _mockUserRepo.Received().SearchByEmail("blah"); } [Fact] public async Task SearchByName() { var service = GetMockedUserService(); var list = new List(); _mockUserRepo.SearchByName("blah").Returns(Task.FromResult(list)); var result = await service.SearchByName("blah"); Assert.Same(list, result); await _mockUserRepo.Received().SearchByName("blah"); } [Fact] public async Task SearchByRole() { var service = GetMockedUserService(); var list = new List(); _mockUserRepo.SearchByRole("blah").Returns(Task.FromResult(list)); var result = await service.SearchByRole("blah"); Assert.Same(list, result); await _mockUserRepo.Received().SearchByRole("blah"); } [Fact] public async Task GetUserEdit() { var service = GetMockedUserService(); _mockProfileRepo.GetProfile(1).Returns(Task.FromResult(new Profile { UserID = 1, Web = "blah"})); var user = new User { UserID = 1 }; user.Roles = new List(); var result = await service.GetUserEdit(user); Assert.Equal(1, result.UserID); Assert.Equal("blah", result.Web); } [Fact] public async Task EditUserProfileOnly() { var service = GetMockedUserService(); var user = new User { UserID = 1 }; user.Roles = new List(); var userEdit = new UserEdit(); var profile = new Profile(); var returnedProfile = GetReturnedProfile(userEdit); _mockProfileRepo.GetProfile(1).Returns(Task.FromResult(returnedProfile)); await _mockProfileRepo.Update(Arg.Do(x => profile = x)); await service.EditUser(user, userEdit, false, false, null, null, "123", user); await _mockUserRepo.DidNotReceive().ChangeEmail(Arg.Any(), Arg.Any()); await _mockUserRepo.DidNotReceive().ChangeName(Arg.Any(), Arg.Any()); await _mockUserRepo.DidNotReceive().SetHashedPassword(Arg.Any(), Arg.Any(), Arg.Any()); await _mockProfileRepo.Received().Update(Arg.Any()); } private Profile GetReturnedProfile(UserEdit userEdit) { return new Profile { UserID = 1, IsSubscribed = userEdit.IsSubscribed, ShowDetails = userEdit.ShowDetails, IsPlainText = userEdit.IsPlainText, HideVanity = userEdit.HideVanity, Signature = userEdit.Signature, Location = userEdit.Location, Dob = userEdit.Dob, Web = userEdit.Web, Instagram = userEdit.Instagram, Facebook = userEdit.Facebook, }; } [Fact] public async Task EditUserApprovalChange() { var service = GetMockedUserService(); var user = new User { UserID = 1, IsApproved = false}; user.Roles = new List(); var userEdit = new UserEdit {IsApproved = true}; _mockProfileRepo.GetProfile(1).Returns(Task.FromResult(GetReturnedProfile(userEdit))); await service.EditUser(user, userEdit, false, false, null, null, "123", user); await _mockUserRepo.Received().UpdateIsApproved(user, true); } [Fact] public async Task EditUserNewEmail() { var service = GetMockedUserService(); var user = new User { UserID = 1, Email = "c@d.com" }; user.Roles = new List(); var userEdit = new UserEdit { NewEmail = "a@b.com" }; _mockProfileRepo.GetProfile(1).Returns(Task.FromResult(GetReturnedProfile(userEdit))); await service.EditUser(user, userEdit, false, false, null, null, "123", user); await _mockUserRepo.Received().ChangeEmail(user, "a@b.com"); } [Fact] public async Task EditUserNewPassword() { var service = GetMockedUserService(); var user = new User { UserID = 1 }; user.Roles = new List(); var userEdit = new UserEdit { NewPassword = "foo" }; _mockProfileRepo.GetProfile(1).Returns(Task.FromResult(GetReturnedProfile(userEdit))); await service.EditUser(user, userEdit, false, false, null, null, "123", user); await _mockUserRepo.Received().SetHashedPassword(user, Arg.Any(), Arg.Any()); } [Fact] public async Task EditUserAddRole() { var service = GetMockedUserService(); var user = new User { UserID = 1 }; user.Roles = new List(); var userEdit = new UserEdit { Roles = new [] {"Admin", "Moderator"} }; _mockProfileRepo.GetProfile(1).Returns(Task.FromResult(GetReturnedProfile(userEdit))); await service.EditUser(user, userEdit, false, false, null, null, "123", user); await _mockRoleRepo.Received().ReplaceUserRoles(1, userEdit.Roles); await _mockSecurityLogService.Received().CreateLogEntry(Arg.Any(), user, "123", "Admin", SecurityLogType.UserAddedToRole); await _mockSecurityLogService.Received().CreateLogEntry(Arg.Any(), user, "123", "Moderator", SecurityLogType.UserAddedToRole); } [Fact] public async Task EditUserRemoveRole() { var service = GetMockedUserService(); var user = new User { UserID = 1 }; user.Roles = new List { "Admin", "Moderator" }; var userEdit = new UserEdit { Roles = new[] { "SomethingElse" } }; _mockProfileRepo.GetProfile(1).Returns(Task.FromResult(GetReturnedProfile(userEdit))); await service.EditUser(user, userEdit, false, false, null, null, "123", user); await _mockRoleRepo.Received().ReplaceUserRoles(1, userEdit.Roles); await _mockSecurityLogService.Received().CreateLogEntry(Arg.Any(), user, "123", "Admin", SecurityLogType.UserRemovedFromRole); await _mockSecurityLogService.Received().CreateLogEntry(Arg.Any(), user, "123", "Moderator", SecurityLogType.UserRemovedFromRole); } [Fact] public async Task EditUserDeleteAvatar() { var service = GetMockedUserService(); var user = new User {UserID = 1, Roles = new List()}; var userEdit = new UserEdit(); var returnedProfile = GetReturnedProfile(userEdit); returnedProfile.AvatarID = 3; _mockProfileRepo.GetProfile(1).Returns(Task.FromResult(returnedProfile)); var profile = new Profile(); await _mockProfileRepo.Update(Arg.Do(x => profile = x)); await service.EditUser(user, userEdit, true, false, null, null, "123", user); await _mockProfileRepo.Received().Update(Arg.Any()); Assert.Null(profile.AvatarID); } [Fact] public async Task EditUserNoDeleteAvatar() { var service = GetMockedUserService(); var user = new User { UserID = 1 }; user.Roles = new List(); var userEdit = new UserEdit(); var returnedProfile = GetReturnedProfile(userEdit); returnedProfile.AvatarID = 3; _mockProfileRepo.GetProfile(1).Returns(Task.FromResult(returnedProfile)); var profile = new Profile(); await _mockProfileRepo.Update(Arg.Do(x => profile = x)); await service.EditUser(user, userEdit, false, false, null, null, "123", user); await _mockProfileRepo.Received().Update(Arg.Any()); Assert.Equal(3, profile.AvatarID); } [Fact] public async Task EditUserDeletePhoto() { var service = GetMockedUserService(); var user = new User { UserID = 1 }; user.Roles = new List(); var userEdit = new UserEdit(); var returnedProfile = GetReturnedProfile(userEdit); returnedProfile.ImageID = 3; _mockProfileRepo.GetProfile(1).Returns(Task.FromResult(returnedProfile)); var profile = new Profile(); await _mockProfileRepo.Update(Arg.Do(x => profile = x)); await service.EditUser(user, userEdit, false, true, null, null, "123", user); await _mockProfileRepo.Received().Update(Arg.Any()); Assert.Null(profile.ImageID); } [Fact] public async Task EditUserNoDeletePhoto() { var service = GetMockedUserService(); var user = new User { UserID = 1 }; user.Roles = new List(); var userEdit = new UserEdit(); var returnedProfile = GetReturnedProfile(userEdit); returnedProfile.ImageID = 3; _mockProfileRepo.GetProfile(1).Returns(Task.FromResult(returnedProfile)); var profile = new Profile(); await _mockProfileRepo.Update(Arg.Do(x => profile = x)); await service.EditUser(user, userEdit, false, false, null, null, "123", user); await _mockProfileRepo.Received().Update(Arg.Any()); Assert.Equal(3, profile.ImageID); } [Fact] public async Task EditUserNewAvatar() { var service = GetMockedUserService(); var user = new User { UserID = 1 }; user.Roles = new List(); var userEdit = new UserEdit(); var returnedProfile = GetReturnedProfile(userEdit); _mockProfileRepo.GetProfile(1).Returns(Task.FromResult(returnedProfile)); _mockProfileRepo.Update(Arg.Any()).Returns(Task.FromResult(true)); _mockUserAvatarRepo.SaveNewAvatar(1, Arg.Any(), Arg.Any()).Returns(Task.FromResult(12)); var image = new byte[1]; await service.EditUser(user, userEdit, false, false, image, null, "123", user); await _mockUserAvatarRepo.Received().SaveNewAvatar(1, image, Arg.Any()); } [Fact] public async Task EditUserNewPhoto() { var service = GetMockedUserService(); var user = new User { UserID = 1 }; user.Roles = new List(); var userEdit = new UserEdit(); var returnedProfile = GetReturnedProfile(userEdit); _mockProfileRepo.GetProfile(1).Returns(Task.FromResult(returnedProfile)); _mockProfileRepo.Update(Arg.Any()).Returns(Task.FromResult(true)); _mockUserImageRepo.SaveNewImage(1, 0, true, Arg.Any(), Arg.Any()).Returns(Task.FromResult(12)); var image = new byte[1]; await service.EditUser(user, userEdit, false, false, null, image, "123", user); await _mockUserImageRepo.Received().SaveNewImage(1, 0, true, image, Arg.Any()); } [Fact] public async Task UserEditPhotosDeleteAvatar() { var service = GetMockedUserService(); var user = new User { UserID = 1 }; var userEdit = new UserEdit(); var returnedProfile = GetReturnedProfile(userEdit); returnedProfile.AvatarID = 3; _mockProfileRepo.GetProfile(1).Returns(Task.FromResult(returnedProfile)); var profile = new Profile(); await _mockProfileRepo.Update(Arg.Do(x => profile = x)); await service.EditUserProfileImages(user, true, false, null, null); await _mockProfileRepo.Received().Update(Arg.Any()); await _mockUserAvatarRepo.Received().DeleteAvatarsByUserID(user.UserID); Assert.Null(profile.AvatarID); } [Fact] public async Task UserEditPhotosNoDeleteAvatar() { var service = GetMockedUserService(); var user = new User { UserID = 1 }; var userEdit = new UserEdit(); var returnedProfile = GetReturnedProfile(userEdit); returnedProfile.AvatarID = 3; _mockProfileRepo.GetProfile(1).Returns(Task.FromResult(returnedProfile)); var profile = new Profile(); await _mockProfileRepo.Update(Arg.Do(x => profile = x)); await service.EditUserProfileImages(user, false, false, null, null); await _mockProfileRepo.Received().Update(Arg.Any()); await _mockUserAvatarRepo.DidNotReceive().DeleteAvatarsByUserID(user.UserID); Assert.Equal(3, profile.AvatarID); } [Fact] public async Task UserEditPhotosDeletePhoto() { var service = GetMockedUserService(); var user = new User { UserID = 1 }; var userEdit = new UserEdit(); var returnedProfile = GetReturnedProfile(userEdit); returnedProfile.ImageID = 3; _mockProfileRepo.GetProfile(1).Returns(Task.FromResult(returnedProfile)); var profile = new Profile(); await _mockProfileRepo.Update(Arg.Do(x => profile = x)); await service.EditUserProfileImages(user, false, true, null, null); await _mockProfileRepo.Received().Update(Arg.Any()); await _mockUserImageRepo.Received().DeleteImagesByUserID(user.UserID); Assert.Null(profile.ImageID); } [Fact] public async Task UserEditPhotosNoDeletePhoto() { var service = GetMockedUserService(); var user = new User { UserID = 1 }; var userEdit = new UserEdit(); var returnedProfile = GetReturnedProfile(userEdit); returnedProfile.ImageID = 3; _mockProfileRepo.GetProfile(1).Returns(Task.FromResult(returnedProfile)); var profile = new Profile(); await _mockProfileRepo.Update(Arg.Do(x => profile = x)); await service.EditUserProfileImages(user, false, false, null, null); await _mockProfileRepo.Received().Update(Arg.Any()); await _mockUserImageRepo.DidNotReceive().DeleteImagesByUserID(user.UserID); Assert.Equal(3, profile.ImageID); } [Fact] public async Task GetUsersOnlineCallsRepo() { var service = GetMockedUserService(); var users = new List(); _mockUserRepo.GetUsersOnline().Returns(Task.FromResult(users)); var result = await service.GetUsersOnline(); await _mockUserRepo.Received().GetUsersOnline(); Assert.Same(users, result); } [Fact] public async Task DeleteUserLogs() { var targetUser = new User { UserID = 1 }; var user = new User { UserID = 2 }; var service = GetMockedUserService(); await service.DeleteUser(targetUser, user, "127.0.0.1", true); await _mockSecurityLogService.Received().CreateLogEntry(user, targetUser, "127.0.0.1", Arg.Any(), SecurityLogType.UserDeleted); } [Fact] public async Task DeleteUserCallsRepo() { var targetUser = new User { UserID = 1 }; var user = new User { UserID = 2 }; var service = GetMockedUserService(); await service.DeleteUser(targetUser, user, "127.0.0.1", true); await _mockUserRepo.Received().DeleteUser(targetUser); } [Fact] public async Task DeleteUserCallsBanRepoIfBanIsTrue() { var targetUser = new User { UserID = 1, Email = "a@b.com" }; var user = new User { UserID = 2 }; var service = GetMockedUserService(); await service.DeleteUser(targetUser, user, "127.0.0.1", true); await _mockBanRepo.Received().BanEmail(targetUser.Email); } [Fact] public async Task DeleteUserDoesNotCallBanRepoIfBanIsFalse() { var targetUser = new User { UserID = 1, Email = "a@b.com" }; var user = new User { UserID = 2 }; var service = GetMockedUserService(); await service.DeleteUser(targetUser, user, "127.0.0.1", false); await _mockBanRepo.DidNotReceive().BanEmail(targetUser.Email); } [Fact] public async Task ForgotPasswordCallsMailerForGoodUser() { var user = new User { UserID = 2, Email = "a@b.com" }; var service = GetMockedUserService(); _mockUserRepo.GetUserByEmail(user.Email).Returns(Task.FromResult(user)); await service.GeneratePasswordResetEmail(user, "http"); await _mockForgotMailer.Received(1).ComposeAndQueue(user, Arg.Any()); } [Fact] public async Task ForgotPasswordGeneratesNewAuthKey() { var user = new User { UserID = 2, Email = "a@b.com" }; var service = GetMockedUserService(); _mockUserRepo.GetUserByEmail(user.Email).Returns(Task.FromResult(user)); await service.GeneratePasswordResetEmail(user, "http"); await _mockUserRepo.Received(1).UpdateAuthorizationKey(user, Arg.Any()); } [Fact] public async Task ForgotPasswordThrowsForNoUser() { var service = GetMockedUserService(); _mockUserRepo.GetUserByEmail(Arg.Any()).Returns((User)null); await Assert.ThrowsAsync(async () => await service.GeneratePasswordResetEmail(null, "http")); await _mockForgotMailer.DidNotReceive().ComposeAndQueue(Arg.Any(), Arg.Any()); } } ================================================ FILE: src/PopForums.Test/Services/UserSessionServiceTests.cs ================================================ namespace PopForums.Test.Services; public class UserSessionServiceTests { private ISettingsManager _mockSettingsManager; private IUserRepository _mockUserRepo; private IUserSessionRepository _mockUserSessionRepo; private ISecurityLogService _mockSecurityLogService; private UserSessionService GetService() { _mockSettingsManager = Substitute.For(); _mockUserRepo = Substitute.For(); _mockUserSessionRepo = Substitute.For(); _mockSecurityLogService = Substitute.For(); var service = new UserSessionService(_mockSettingsManager, _mockUserRepo, _mockUserSessionRepo, _mockSecurityLogService); return service; } [Fact] public async Task AnonUserNoCookieGetsCookieAndSessionStart() { var service = GetService(); var deleteCalled = false; Action delete = () => { deleteCalled = true; }; int? createResult = null; Action create = i => { createResult = i; }; await service.ProcessUserRequest(null, null, "1.1.1.1", delete, create); Assert.False(deleteCalled); Assert.True(createResult.HasValue); await _mockUserSessionRepo.Received().CreateSession(Arg.Any(), null, Arg.Any()); await _mockSecurityLogService.Received().CreateLogEntry((int?)null, null, "1.1.1.1", createResult.Value.ToString(), SecurityLogType.UserSessionStart); await _mockUserRepo.DidNotReceive().UpdateLastActivityDate(Arg.Any(), Arg.Any()); } [Fact] public async Task AnonUserWithCookieUpdateSession() { var service = GetService(); var deleteCalled = false; Action delete = () => { deleteCalled = true; }; int? createResult = null; Action create = i => { createResult = i; }; const int sessionID = 5467; _mockUserSessionRepo.UpdateSession(sessionID, Arg.Any()).Returns(Task.FromResult(true)); _mockUserSessionRepo.IsSessionAnonymous(sessionID).Returns(Task.FromResult(true)); var result = await service.ProcessUserRequest(null, sessionID, "1.1.1.1", delete, create); Assert.False(deleteCalled); Assert.Equal(sessionID, result); await _mockUserSessionRepo.Received().UpdateSession(sessionID, Arg.Any()); await _mockUserRepo.DidNotReceive().UpdateLastActivityDate(Arg.Any(), Arg.Any()); } [Fact] public async Task UserWithAnonCookieStartsLoggedInSession() { var user = new User { UserID = 123 }; var service = GetService(); var deleteCalled = false; Action delete = () => { deleteCalled = true; }; int? createResult = null; Action create = i => { createResult = i; }; const int sessionID = 5467; _mockUserSessionRepo.UpdateSession(sessionID, Arg.Any()).Returns(Task.FromResult(true)); _mockUserSessionRepo.IsSessionAnonymous(sessionID).Returns(Task.FromResult(true)); var result = await service.ProcessUserRequest(user, sessionID, "1.1.1.1", delete, create); Assert.True(deleteCalled); Assert.Equal(createResult, result); await _mockUserSessionRepo.Received().UpdateSession(sessionID, Arg.Any()); await _mockUserRepo.Received().UpdateLastActivityDate(user, Arg.Any()); await _mockUserSessionRepo.Received().DeleteSessions(null, sessionID); await _mockSecurityLogService.Received().CreateLogEntry(null, null, String.Empty, sessionID.ToString(), SecurityLogType.UserSessionEnd, Arg.Any()); await _mockUserSessionRepo.Received().CreateSession(Arg.Any(), user.UserID, Arg.Any()); await _mockSecurityLogService.Received().CreateLogEntry(null, user.UserID, Arg.Any(), Arg.Any(), SecurityLogType.UserSessionStart); } [Fact] public async Task AnonUserWithLoggedInSessionEndsOldOneStartsNewOne() { var service = GetService(); var deleteCalled = false; Action delete = () => { deleteCalled = true; }; int? createResult = null; Action create = i => { createResult = i; }; const int sessionID = 5467; _mockUserSessionRepo.UpdateSession(sessionID, Arg.Any()).Returns(Task.FromResult(true)); _mockUserSessionRepo.IsSessionAnonymous(sessionID).Returns(Task.FromResult(false)); var result = await service.ProcessUserRequest(null, sessionID, "1.1.1.1", delete, create); Assert.True(deleteCalled); Assert.Equal(createResult, result); await _mockUserSessionRepo.Received().UpdateSession(sessionID, Arg.Any()); await _mockUserSessionRepo.Received().DeleteSessions(null, sessionID); await _mockSecurityLogService.Received().CreateLogEntry(null, null, String.Empty, sessionID.ToString(), SecurityLogType.UserSessionEnd, Arg.Any()); await _mockUserSessionRepo.Received().CreateSession(Arg.Any(), null, Arg.Any()); await _mockSecurityLogService.Received().CreateLogEntry((int?)null, null, Arg.Any(), Arg.Any(), SecurityLogType.UserSessionStart); } [Fact] public async Task UserWithNoCookieGetsCookieAndSessionStart() { var user = new User { UserID = 123 }; var service = GetService(); var deleteCalled = false; Action delete = () => { deleteCalled = true; }; int? createResult = null; Action create = i => { createResult = i; }; _mockUserSessionRepo.GetSessionIDByUserID(Arg.Any()).Returns((ExpiredUserSession)null); var result = await service.ProcessUserRequest(user, null, "1.1.1.1", delete, create); Assert.False(deleteCalled); Assert.Equal(createResult, result); await _mockUserSessionRepo.Received().CreateSession(Arg.Any(), 123, Arg.Any()); await _mockSecurityLogService.Received().CreateLogEntry(null, user.UserID, Arg.Any(), result.ToString(), SecurityLogType.UserSessionStart); await _mockUserRepo.Received().UpdateLastActivityDate(user, Arg.Any()); } [Fact] public async Task UserWithCookieUpdateSession() { var user = new User { UserID = 123 }; var service = GetService(); var deleteCalled = false; Action delete = () => { deleteCalled = true; }; int? createResult = null; Action create = i => { createResult = i; }; const int sessionID = 5467; _mockUserSessionRepo.UpdateSession(sessionID, Arg.Any()).Returns(Task.FromResult(true)); var result = await service.ProcessUserRequest(user, sessionID, "1.1.1.1", delete, create); Assert.Null(createResult); Assert.False(deleteCalled); Assert.Equal(sessionID, result); await _mockUserSessionRepo.Received().UpdateSession(sessionID, Arg.Any()); await _mockUserRepo.Received().UpdateLastActivityDate(user, Arg.Any()); } [Fact] public async Task UserSessionNoCookieButHasOldSessionEndsOldSessionStartsNewOne() { var user = new User { UserID = 123 }; var service = GetService(); Action delete = () => { }; int? createResult = null; Action create = i => { createResult = i; }; const int sessionID = 5467; _mockUserSessionRepo.GetSessionIDByUserID(user.UserID).Returns(Task.FromResult(new ExpiredUserSession { UserID = user.UserID, SessionID = sessionID, LastTime = DateTime.MinValue })); var result = await service.ProcessUserRequest(user, sessionID, "1.1.1.1", delete, create); Assert.Equal(createResult, result); await _mockUserSessionRepo.Received().DeleteSessions(user.UserID, sessionID); await _mockSecurityLogService.Received().CreateLogEntry(null, user.UserID, String.Empty, sessionID.ToString(), SecurityLogType.UserSessionEnd, DateTime.MinValue); await _mockUserSessionRepo.Received().CreateSession(Arg.Any(), user.UserID, Arg.Any()); await _mockSecurityLogService.Received().CreateLogEntry(null, user.UserID, "1.1.1.1", createResult.ToString(), SecurityLogType.UserSessionStart); await _mockUserRepo.Received().UpdateLastActivityDate(user, Arg.Any()); } [Fact] public async Task UserSessionWithNoMatchingIDEndsOldSessionStartsNewOne() { var user = new User { UserID = 123 }; var service = GetService(); Action delete = () => { }; int? createResult = null; Action create = i => { createResult = i; }; const int sessionID = 5467; _mockUserSessionRepo.GetSessionIDByUserID(user.UserID).Returns(Task.FromResult(new ExpiredUserSession { UserID = user.UserID, SessionID = sessionID, LastTime = DateTime.MinValue })); var result = await service.ProcessUserRequest(user, sessionID, "1.1.1.1", delete, create); Assert.Equal(createResult, result); await _mockUserSessionRepo.Received().UpdateSession(sessionID, Arg.Any()); await _mockUserSessionRepo.Received().DeleteSessions(user.UserID, sessionID); await _mockSecurityLogService.Received().CreateLogEntry(null, user.UserID, String.Empty, sessionID.ToString(), SecurityLogType.UserSessionEnd, DateTime.MinValue); await _mockUserSessionRepo.Received().CreateSession(Arg.Any(), user.UserID, Arg.Any()); await _mockSecurityLogService.Received().CreateLogEntry(null, user.UserID, Arg.Any(), Arg.Any(), SecurityLogType.UserSessionStart); await _mockUserRepo.Received().UpdateLastActivityDate(user, Arg.Any()); } [Fact] public async Task CleanExpiredSessions() { var service = GetService(); var sessions = new List { new ExpiredUserSession { SessionID = 123, UserID = null, LastTime = new DateTime(2000, 2, 5)}, new ExpiredUserSession { SessionID = 789, UserID = 456, LastTime = new DateTime(2010, 3, 6)} }; _mockUserSessionRepo.GetAndDeleteExpiredSessions(Arg.Any()).Returns(Task.FromResult(sessions)); _mockSettingsManager.Current.SessionLength.Returns(20); await service.CleanUpExpiredSessions(); await _mockSecurityLogService.Received().CreateLogEntry(null, sessions[0].UserID, String.Empty, sessions[0].SessionID.ToString(), SecurityLogType.UserSessionEnd, sessions[0].LastTime); await _mockSecurityLogService.Received().CreateLogEntry(null, sessions[1].UserID, String.Empty, sessions[1].SessionID.ToString(), SecurityLogType.UserSessionEnd, sessions[1].LastTime); } } ================================================ FILE: src/PopForums.Test/Services/UserSessionWorkerTests.cs ================================================ using NSubstitute.ExceptionExtensions; namespace PopForums.Test.Services; public class UserSessionWorkerTests { private IUserSessionService _userSessionService; private IErrorLog _errorLog; private UserSessionWorker GetWorker() { _userSessionService = Substitute.For(); _errorLog = Substitute.For(); return new UserSessionWorker(_userSessionService, _errorLog); } [Fact] public void NoErrorNoLog() { var worker = GetWorker(); _userSessionService.CleanUpExpiredSessions().Returns(Task.CompletedTask); worker.Execute(); _errorLog.DidNotReceive().Log(Arg.Any(), Arg.Any()); } [Fact] public void LogWhenThrows() { var worker = GetWorker(); _userSessionService.CleanUpExpiredSessions().ThrowsAsync(); worker.Execute(); _errorLog.Received().Log(Arg.Any(), ErrorSeverity.Error); } } ================================================ FILE: src/PopForums.Web/Controllers/HomeController.cs ================================================ using Microsoft.AspNetCore.Mvc; namespace PopForums.Mvc.Controllers { public class HomeController : Controller { public IActionResult Index() { return View(); } } } ================================================ FILE: src/PopForums.Web/PopForums.Web.csproj ================================================  net10.0 22.0.0 PopForums.Web PopForums.Web ================================================ FILE: src/PopForums.Web/Program.cs ================================================ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using PopForums.AzureKit; using PopForums.Mvc.Areas.Forums.Authorization; using PopForums.Mvc.Areas.Forums.Extensions; using PopForums.Sql; using System.Text.Json.Serialization; using PopForums.ElasticKit; using PopForums.Extensions; using Microsoft.Extensions.Logging; using Microsoft.AspNetCore.ResponseCompression; using System.IO.Compression; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Hosting; var builder = WebApplication.CreateBuilder(args); var services = builder.Services; // the following block is for use when you have multiple nodes running // (think Azure App Services) so they can all decode the auth cookie var configuration = builder.Configuration; if (configuration["DataProtectBlobConnectionString"] != null) { services.AddDataProtection() .SetApplicationName("popforumsdev") .PersistKeysToAzureBlobStorage(configuration["DataProtectBlobConnectionString"], "keys", "antiforge"); } services.AddControllersWithViews(); services.AddRazorPages(); services.Configure(options => { // sets claims policies for admin and moderator functions in POP Forums options.AddPopForumsPolicies(); }); services.AddMvc(options => { // identifies users on POP Forums actions options.Filters.Add(typeof(PopForumsUserAttribute)); }); services.AddControllers().AddJsonOptions(options => { // Use this to make sure enums are serialized correctly options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); options.JsonSerializerOptions.PropertyNameCaseInsensitive = true; }); // set up the dependencies for the SQL library in POP Forums services.AddPopForumsSql(); // this adds dependencies from the MVC project (and base dependencies) and sets up authentication for the forum services.AddPopForumsMvc(); // use Azure table storage for logging instead of database //services.AddPopForumsTableStorageLogging(); // use Redis cache for POP Forums using AzureKit //services.AddPopForumsRedisCache(); // required for real-time updating of POP Forums services.AddSignalR(); // use this instead of previous line if you need to route SignalR messages // over a Redis backplane for multi-instance host //services.AddSignalR().AddRedisBackplaneForPopForums(); // use Azure Search for POP Forums using AzureKit //services.AddPopForumsAzureSearch(); // use ElasticSearch for POP Forums using ElasticKit //services.AddPopForumsElasticSearch(); // use Azure Functions queues for POP Forums using AzureKit for background tasks... // do NOT call AddPopForumsBackgroundJobs() services.AddPopForumsAzureFunctionsAndQueues(); // persist image uploads to Azure blob storage, see configuration services.AddPopForumsAzureBlobStorageForPostImages(); // creates an instance of the background services for POP Forums... call this last in forum setup, // but don't use if you're running these in functions with AddPopForumsAzureFunctionsAndQueues() //services.AddPopForumsBackgroundJobs(); // send fewer bits services.AddResponseCompression(options => { options.Providers.Add(); options.Providers.Add(); options.EnableForHttps = true; }); services.Configure(options => options.Level = CompressionLevel.Fastest); services.Configure(options => options.Level = CompressionLevel.Fastest); var app = builder.Build(); if (!app.Environment.IsDevelopment()) { // send fewer bits app.UseResponseCompression(); } // Records exceptions and info to the POP Forums database. var loggerFactory = app.Services.GetService(); loggerFactory.AddPopForumsLogger(app); app.UseStaticFiles(); // Enables languages app.UsePopForumsCultures(); // Not unique to POP Forums, but required. Call before UsePopForumsAuth(). app.UseAuthentication(); app.UseDeveloperExceptionPage(); // Add MVC to the request pipeline. The order of the next three lines matters: app.UseRouting(); // Populate the POP Forums identity in every request. // Possible breaking change starting in v21. This must be called after UseRouting() // but before UseAuthorization() and endpoint mapping. app.UsePopForumsAuth(); app.UseAuthorization(); // POP Forums routes app.AddPopForumsEndpoints(); // app routes app.MapControllerRoute( "areaRoute", "{area:exists}/{controller=Home}/{action=Index}/{id?}"); app.MapControllerRoute( "default", "{controller=Home}/{action=Index}/{id?}"); app.Run(); ================================================ FILE: src/PopForums.Web/Properties/launchSettings.json ================================================ { "profiles": { "PopForumsKestrel": { "commandName": "Project", "launchBrowser": true, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, "applicationUrl": "https://localhost:5091;http://localhost:5090", "dotnetRunMessages": true } } } ================================================ FILE: src/PopForums.Web/Views/Home/Index.cshtml ================================================ @{ ViewData["Title"] = "Home Page"; }

    forums

    PopForums - v@(System.Reflection.Assembly.Load("PopForums").GetName().Version)
    PopForums.Sql - v@(System.Reflection.Assembly.Load("PopForums.Sql").GetName().Version)
    PopForums.Mvc - v@(System.Reflection.Assembly.Load("PopForums.Mvc").GetName().Version)
    PopForums.Web - v@(System.Reflection.Assembly.Load("PopForums.Web").GetName().Version)

    ================================================ FILE: src/PopForums.Web/Views/Shared/_Layout.cshtml ================================================  @ViewBag.Title @await RenderSectionAsync("HeaderContent", false)
    @RenderBody()
    ================================================ FILE: src/PopForums.Web/Views/_ViewImports.cshtml ================================================ @using PopForums.Mvc @addTagHelper "*, Microsoft.AspNetCore.Mvc.TagHelpers" ================================================ FILE: src/PopForums.Web/Views/_ViewStart.cshtml ================================================ @{ Layout = "_Layout"; } ================================================ FILE: src/PopForums.Web/appsettings.json ================================================ { "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*", "PopForums": { "IpLookupUrlFormat": "https://whatismyipaddress.com/ip/{0}", "BaseImageBlobUrl": "http://127.0.0.1:10000/devstoreaccount1", "Storage": { "ConnectionString": "UseDevelopmentStorage=true" }, "Database": { "ConnectionString": "server=localhost;Database=popforums21;Trusted_Connection=True;TrustServerCertificate=True;" }, "Cache": { "Seconds": 180, "ConnectionString": "127.0.0.1:6379,abortConnect=false", "ForceLocalOnly": false }, "Search": { "Url": "https://localhost:9200", "Key": "99011A70D3D50D251B0A6141A97B40E7" }, "Queue": { "ConnectionString": "UseDevelopmentStorage=true" }, "LogTopicViews": true, "ReCaptcha": { "UseReCaptcha": true, "SiteKey": "6Lc2drIUAAAAAPaa1iHozzu0Zt9rjCYHhjk4Jvtr", "SecretKey": "6Lc2drIUAAAAADXBXpTjMp67L-T5HdLe7OoKlLrG" }, "RenderBootstrap": true, "OAuthOnly": { "IsOAuthOnly": false, "OAuthClientID": "", "OAuthClientSecret": "", "OAuthLoginBaseUrl": "", "OAuthTokenUrl": "", "OAuthAdminClaimType": "", "OAuthAdminClaimValue": "", "OAuthModeratorClaimType": "", "OAuthModeratorClaimValue": "", "OAuthScopes": "", "OAuthRefreshExpirationMinutes": "" } } }