diff --git a/lib/src/config/app_dependencies.dart b/lib/src/config/app_dependencies.dart index da51e5e..1b6d1e4 100644 --- a/lib/src/config/app_dependencies.dart +++ b/lib/src/config/app_dependencies.dart @@ -16,7 +16,7 @@ import 'package:flutter_news_app_api_server_full_source_code/src/services/countr import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/database_migration_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/database_seeding_service.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/services/default_user_preference_limit_service.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/default_user_action_limit_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/firebase_authenticator.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/firebase_push_notification_client.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/jwt_auth_token_service.dart'; @@ -28,7 +28,7 @@ import 'package:flutter_news_app_api_server_full_source_code/src/services/push_n import 'package:flutter_news_app_api_server_full_source_code/src/services/push_notification_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/rate_limit_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/token_blacklist_service.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/services/user_preference_limit_service.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/user_action_limit_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/verification_code_storage_service.dart'; import 'package:http_client/http_client.dart'; import 'package:logging/logging.dart'; @@ -73,6 +73,8 @@ class AppDependencies { late final DataRepository remoteConfigRepository; late final DataRepository inAppNotificationRepository; + late final DataRepository engagementRepository; + late final DataRepository reportRepository; late final EmailRepository emailRepository; // Services @@ -83,7 +85,7 @@ class AppDependencies { late final AuthService authService; late final DashboardSummaryService dashboardSummaryService; late final PermissionService permissionService; - late final UserPreferenceLimitService userPreferenceLimitService; + late final UserActionLimitService userActionLimitService; late final RateLimitService rateLimitService; late final CountryQueryService countryQueryService; late final IPushNotificationService pushNotificationService; @@ -231,6 +233,22 @@ class AppDependencies { _log.info('Initialized data client for InAppNotification.'); + final engagementClient = DataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'engagements', + fromJson: Engagement.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('DataMongodb'), + ); + + final reportClient = DataMongodb( + connectionManager: _mongoDbConnectionManager, + modelName: 'reports', + fromJson: Report.fromJson, + toJson: (item) => item.toJson(), + logger: Logger('DataMongodb'), + ); + // --- Conditionally Initialize Push Notification Clients --- // Firebase @@ -319,6 +337,9 @@ class AppDependencies { inAppNotificationRepository = DataRepository( dataClient: inAppNotificationClient, ); + engagementRepository = DataRepository(dataClient: engagementClient); + reportRepository = DataRepository(dataClient: reportClient); + // Configure the HTTP client for SendGrid. // The HttpClient's AuthInterceptor will use the tokenProvider to add // the 'Authorization: Bearer ' header. @@ -368,9 +389,11 @@ class AppDependencies { topicRepository: topicRepository, sourceRepository: sourceRepository, ); - userPreferenceLimitService = DefaultUserPreferenceLimitService( + userActionLimitService = DefaultUserActionLimitService( remoteConfigRepository: remoteConfigRepository, - log: Logger('DefaultUserPreferenceLimitService'), + engagementRepository: engagementRepository, + reportRepository: reportRepository, + log: Logger('DefaultUserActionLimitService'), ); rateLimitService = MongoDbRateLimitService( connectionManager: _mongoDbConnectionManager, diff --git a/lib/src/config/model_registry.dart b/lib/src/config/model_registry.dart new file mode 100644 index 0000000..67ea336 --- /dev/null +++ b/lib/src/config/model_registry.dart @@ -0,0 +1,567 @@ +// ignore_for_file: comment_references + +import 'package:core/core.dart'; +import 'package:dart_frog/dart_frog.dart'; +import 'package:data_client/data_client.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permissions.dart'; + +/// Defines the type of permission check required for a specific action. +enum RequiredPermissionType { + /// No specific permission check is required (e.g., public access). + /// Note: This assumes the parent route group middleware allows unauthenticated + /// access if needed. The /data route requires authentication by default. + none, + + /// Requires the user to have the [UserRole.admin] role. + adminOnly, + + /// Requires the user to have a specific permission string. + specificPermission, + + /// This action is not supported via this generic route. + /// It is typically handled by a dedicated service or route. + unsupported, +} + +/// Configuration for the authorization requirements of a single HTTP method +/// on a data model. +class ModelActionPermission { + /// {@macro model_action_permission} + const ModelActionPermission({ + required this.type, + this.permission, + this.requiresOwnershipCheck = false, + this.requiresAuthentication = true, + }) : assert( + type != RequiredPermissionType.specificPermission || + permission != null, + 'Permission string must be provided for specificPermission type', + ); + + /// The type of permission check required. + final RequiredPermissionType type; + + /// The specific permission string required if [type] is + /// [RequiredPermissionType.specificPermission]. + final String? permission; + + /// Whether an additional check is required to verify the authenticated user + /// is the owner of the specific data item being accessed (for item-specific + /// methods like GET, PUT, DELETE on `/[id]`). + final bool requiresOwnershipCheck; + + /// Whether this action requires an authenticated user. + /// + /// If `true` (default), the `authenticationProvider` middleware will ensure + /// a valid [User] is present in the context. If `false`, the action can + /// be performed by unauthenticated clients. + final bool requiresAuthentication; +} + +/// {@template model_config} +/// Configuration holder for a specific data model type [T]. +/// +/// This class encapsulates the type-specific operations (like deserialization +/// from JSON, ID extraction, and owner ID extraction) and authorization +/// requirements needed by the generic `/api/v1/data` endpoint handlers and +/// middleware. It allows those handlers to work with different data models +/// without needing explicit type checks for these common operations. +/// +/// An instance of this config is looked up via the [modelRegistry] based on the +/// `?model=` query parameter provided in the request. +/// {@endtemplate} +class ModelConfig { + /// {@macro model_config} + const ModelConfig({ + required this.fromJson, + required this.getId, + required this.getCollectionPermission, + required this.getItemPermission, + required this.postPermission, + required this.putPermission, + required this.deletePermission, + this.getOwnerId, // Optional: Function to get owner ID for user-owned models + }); + + /// Function to deserialize JSON into an object of type [T]. + final FromJson fromJson; + + /// Function to extract the unique string ID from an item of type [T]. + final String Function(T item) getId; + + /// Optional function to extract the unique string ID of the owner from an + /// item of type [T]. Required for models where `requiresOwnershipCheck` + /// is true for any action. + final String? Function(T item)? getOwnerId; + + /// Authorization configuration for GET requests to the collection endpoint. + final ModelActionPermission getCollectionPermission; + + /// Authorization configuration for GET requests to a specific item endpoint. + final ModelActionPermission getItemPermission; + + /// Authorization configuration for POST requests. + final ModelActionPermission postPermission; + + /// Authorization configuration for PUT requests. + final ModelActionPermission putPermission; + + /// Authorization configuration for DELETE requests. + final ModelActionPermission deletePermission; +} + +/// {@template model_registry} +/// Central registry mapping model name strings (used in the `?model=` query parameter) +/// to their corresponding [ModelConfig] instances. +/// +/// This registry is the core component enabling the generic `/api/v1/data` endpoint. +/// The middleware (`routes/api/v1/data/_middleware.dart`) uses this map to: +/// 1. Validate the `model` query parameter provided by the client. +/// 2. Retrieve the correct [ModelConfig] containing type-specific functions +/// (like `fromJson`, `getOwnerId`) and authorization metadata needed by the +/// generic route handlers (`index.dart`, `[id].dart`) and authorization middleware. +/// +/// While individual repositories (`DataRepository`, etc.) are provided +/// directly in the main `routes/_middleware.dart`, this registry provides the +/// *metadata* needed to work with those repositories generically based on the +/// request's `model` parameter. +/// {@endtemplate} +final modelRegistry = >{ + 'headline': ModelConfig( + fromJson: Headline.fromJson, + getId: (h) => h.id, + // Headlines: Admin-owned, read allowed by standard/guest users + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.headlineRead, + requiresAuthentication: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.headlineRead, + requiresAuthentication: true, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + requiresAuthentication: true, + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + requiresAuthentication: true, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + requiresAuthentication: true, + ), + ), + 'topic': ModelConfig( + fromJson: Topic.fromJson, + getId: (t) => t.id, + // Topics: Admin-owned, read allowed by standard/guest users + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.topicRead, + requiresAuthentication: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.topicRead, + requiresAuthentication: true, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + requiresAuthentication: true, + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + requiresAuthentication: true, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + requiresAuthentication: true, + ), + ), + 'source': ModelConfig( + fromJson: Source.fromJson, + getId: (s) => s.id, + // Sources: Admin-owned, read allowed by standard/guest users + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.sourceRead, + requiresAuthentication: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.sourceRead, + requiresAuthentication: true, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + requiresAuthentication: true, + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + requiresAuthentication: true, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + requiresAuthentication: true, + ), + ), + 'country': ModelConfig( + fromJson: Country.fromJson, + getId: (c) => c.id, + // Countries: Static data, read-only for all authenticated users. + // Modification is not allowed via the API as this is real-world data + // managed by database seeding. + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.countryRead, + requiresAuthentication: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.countryRead, + requiresAuthentication: true, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + ), + ), + 'language': ModelConfig( + fromJson: Language.fromJson, + getId: (l) => l.id, + // Languages: Static data, read-only for all authenticated users. + // Modification is not allowed via the API as this is real-world data + // managed by database seeding. + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.languageRead, + requiresAuthentication: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.languageRead, + requiresAuthentication: true, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + ), + ), + 'user': ModelConfig( + fromJson: User.fromJson, + getId: (u) => u.id, + getOwnerId: (dynamic item) => + (item as User).id as String?, // User is the owner of their profile + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, // Only admin can list all users + requiresAuthentication: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.userReadOwned, // User can read their own + requiresOwnershipCheck: true, // Must be the owner + requiresAuthentication: true, + ), + // User creation is handled exclusively by the authentication service + // (e.g., during sign-up) and is not supported via the generic data API. + postPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + ), + // User updates are handled by a custom updater in DataOperationRegistry. + // - Admins can update roles (`appRole`, `dashboardRole`). + // - Users can update their own `feedDecoratorStatus` and `email`. + // The `userUpdateOwned` permission, combined with the ownership check, + // provides the entry point for both admins (who bypass ownership checks) + // and users to target a user object for an update. + putPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.userUpdateOwned, // User can update their own + requiresOwnershipCheck: true, // Must be the owner + requiresAuthentication: true, + ), + // User deletion is handled exclusively by the authentication service + // (e.g., via a dedicated "delete account" endpoint) and is not + // supported via the generic data API. + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + ), + ), + 'app_settings': ModelConfig( + fromJson: AppSettings.fromJson, + getId: (s) => s.id, + getOwnerId: (dynamic item) => (item as AppSettings).id, + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, // Not accessible via collection + requiresAuthentication: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.appSettingsReadOwned, + requiresOwnershipCheck: true, + requiresAuthentication: true, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + // Creation of AppSettings is handled by the authentication service + // during user creation, not via a direct POST to /api/v1/data. + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.appSettingsUpdateOwned, + requiresOwnershipCheck: true, + requiresAuthentication: true, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + // Deletion of AppSettings is handled by the authentication service + // during account deletion, not via a direct DELETE to /api/v1/data. + ), + ), + 'user_content_preferences': ModelConfig( + fromJson: UserContentPreferences.fromJson, + getId: (p) => p.id, + getOwnerId: (dynamic item) => + (item as UserContentPreferences).id + as String?, // User ID is the owner ID + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, // Not accessible via collection + requiresAuthentication: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.userContentPreferencesReadOwned, + requiresOwnershipCheck: true, + requiresAuthentication: true, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + // Creation of UserContentPreferences is handled by the authentication + // service during user creation, not via a direct POST to /api/v1/data. + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.userContentPreferencesUpdateOwned, + requiresOwnershipCheck: true, + requiresAuthentication: true, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + // Deletion of UserContentPreferences is handled by the authentication + // service during account deletion, not via a direct DELETE to /api/v1/data. + ), + ), + 'remote_config': ModelConfig( + fromJson: RemoteConfig.fromJson, + getId: (config) => config.id, + getOwnerId: null, // RemoteConfig is a global resource, not user-owned + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, // Not accessible via collection + requiresAuthentication: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.none, + requiresAuthentication: false, // Make remote_config GET public + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, // Only administrators can create + requiresAuthentication: true, + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, // Only administrators can update + requiresAuthentication: true, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, // Only administrators can delete + requiresAuthentication: true, + ), + ), + 'dashboard_summary': ModelConfig( + fromJson: DashboardSummary.fromJson, + getId: (summary) => summary.id, + getOwnerId: null, // Not a user-owned resource + // Permissions: Read-only for admins, all other actions unsupported. + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + requiresAuthentication: true, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + requiresAuthentication: true, + ), + ), + 'push_notification_device': ModelConfig( + fromJson: PushNotificationDevice.fromJson, + getId: (d) => d.id, + getOwnerId: (dynamic item) => (item as PushNotificationDevice).userId, + // Collection GET is allowed for a user to fetch their own notification devices. + // The generic route handler will automatically scope the query to the + // authenticated user's ID because `getOwnerId` is defined. + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.pushNotificationDeviceReadOwned, + ), + // Item GET is allowed for a user to fetch a single one of their devices. + // The ownership check middleware will verify they own this specific item. + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.pushNotificationDeviceReadOwned, + requiresOwnershipCheck: true, + ), + // POST is allowed for any authenticated user to register their own device. + // A custom check within the DataOperationRegistry's creator function will + // ensure the `userId` in the request body matches the authenticated user. + postPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.pushNotificationDeviceCreateOwned, + // Ownership check is on the *new* item's payload, which is handled + // by the creator function, not the standard ownership middleware. + requiresOwnershipCheck: false, + ), + // PUT is not supported. To update a token, the client should delete the + // old device registration and create a new one. + putPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + ), + // DELETE is allowed for any authenticated user to delete their own device + // registration (e.g., on sign-out). The ownership check middleware will + // verify the user owns the device record before allowing deletion. + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.pushNotificationDeviceDeleteOwned, + requiresOwnershipCheck: true, + ), + ), + 'in_app_notification': ModelConfig( + fromJson: InAppNotification.fromJson, + getId: (n) => n.id, + getOwnerId: (dynamic item) => (item as InAppNotification).userId, + // Collection GET is allowed for a user to fetch their own notification inbox. + // The ownership check ensures they only see their own notifications. + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.inAppNotificationReadOwned, + requiresOwnershipCheck: true, + ), + // Item GET is allowed for a user to fetch a single notification. + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.inAppNotificationReadOwned, + requiresOwnershipCheck: true, + ), + // POST is unsupported as notifications are created by the system, not users. + postPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + ), + // PUT is allowed for a user to update their own notification (e.g., mark as read). + putPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.inAppNotificationUpdateOwned, + requiresOwnershipCheck: true, + ), + // DELETE is allowed for a user to delete their own notification. + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.inAppNotificationDeleteOwned, + requiresOwnershipCheck: true, + ), + ), + 'engagement': ModelConfig( + fromJson: Engagement.fromJson, + getId: (e) => e.id, + getOwnerId: (dynamic item) => (item as Engagement).userId, + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.engagementReadOwned, + requiresOwnershipCheck: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.engagementReadOwned, + requiresOwnershipCheck: true, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.engagementCreateOwned, + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.engagementUpdateOwned, + requiresOwnershipCheck: true, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.engagementDeleteOwned, + requiresOwnershipCheck: true, + ), + ), + 'report': ModelConfig( + fromJson: Report.fromJson, + getId: (r) => r.id, + getOwnerId: (dynamic item) => (item as Report).reporterUserId, + getCollectionPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.reportReadOwned, + requiresOwnershipCheck: true, + ), + getItemPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.reportCreateOwned, + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.unsupported, + ), + ), +}; + +/// Type alias for the ModelRegistry map for easier provider usage. +typedef ModelRegistryMap = Map>; + +/// Dart Frog provider function factory for the entire [modelRegistry]. +/// +/// This makes the `modelRegistry` map available for injection into the +/// request context via `context.read()`. It's primarily +/// used by the middleware in `routes/api/v1/data/_middleware.dart`. +final Middleware modelRegistryProvider = provider( + (_) => modelRegistry, +); diff --git a/lib/src/rbac/permissions.dart b/lib/src/rbac/permissions.dart index 58761c6..6e43f56 100644 --- a/lib/src/rbac/permissions.dart +++ b/lib/src/rbac/permissions.dart @@ -103,4 +103,24 @@ abstract class Permissions { /// Allows deleting the user's own in-app notifications. static const String inAppNotificationDeleteOwned = 'in_app_notification.delete_owned'; + + // Engagement Permissions (User-owned) + /// Allows creating an engagement (reaction or comment). + static const String engagementCreateOwned = 'engagement.create_owned'; + + /// Allows reading the user's own engagements. + static const String engagementReadOwned = 'engagement.read_owned'; + + /// Allows updating the user's own engagement (e.g., changing a reaction). + static const String engagementUpdateOwned = 'engagement.update_owned'; + + /// Allows deleting the user's own engagement. + static const String engagementDeleteOwned = 'engagement.delete_owned'; + + // Report Permissions (User-owned) + /// Allows creating a report. + static const String reportCreateOwned = 'report.create_owned'; + + /// Allows reading the user's own reports. + static const String reportReadOwned = 'report.read_owned'; } diff --git a/lib/src/rbac/role_permissions.dart b/lib/src/rbac/role_permissions.dart index 96b77a7..6c5288c 100644 --- a/lib/src/rbac/role_permissions.dart +++ b/lib/src/rbac/role_permissions.dart @@ -25,10 +25,19 @@ final Set _appGuestUserPermissions = { Permissions.pushNotificationDeviceCreateOwned, Permissions.pushNotificationDeviceDeleteOwned, Permissions.pushNotificationDeviceReadOwned, + // Allow all app users to manage their own in-app notifications. Permissions.inAppNotificationReadOwned, Permissions.inAppNotificationUpdateOwned, Permissions.inAppNotificationDeleteOwned, + + // UGC Permissions + Permissions.engagementCreateOwned, + Permissions.engagementReadOwned, + Permissions.engagementUpdateOwned, + Permissions.engagementDeleteOwned, + Permissions.reportCreateOwned, + Permissions.reportReadOwned, }; final Set _appStandardUserPermissions = { diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index b4fbc60..28b8c2e 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -9,7 +9,7 @@ import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission import 'package:flutter_news_app_api_server_full_source_code/src/services/country_query_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/push_notification_service.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/services/user_preference_limit_service.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/user_action_limit_service.dart'; import 'package:logging/logging.dart'; // --- Typedefs for Data Operations --- @@ -128,6 +128,10 @@ class DataOperationRegistry { 'push_notification_device': (c, id) => c .read>() .read(id: id, userId: null), + 'engagement': (c, id) => + c.read>().read(id: id, userId: null), + 'report': (c, id) => + c.read>().read(id: id, userId: null), }); // --- Register "Read All" Readers --- @@ -191,6 +195,19 @@ class DataOperationRegistry { sort: s, pagination: p, ), + 'engagement': (c, uid, f, s, p) => + c.read>().readAll( + userId: uid, + filter: f, + sort: s, + pagination: p, + ), + 'report': (c, uid, f, s, p) => c.read>().readAll( + userId: uid, + filter: f, + sort: s, + pagination: p, + ), }); // --- Register Item Creators --- @@ -279,6 +296,50 @@ class DataOperationRegistry { userId: null, ); }, + 'engagement': (context, item, uid) async { + _log.info('Executing custom creator for engagement.'); + final authenticatedUser = context.read(); + final userActionLimitService = context.read(); + final engagementToCreate = item as Engagement; + + // Security Check + if (engagementToCreate.userId != authenticatedUser.id) { + throw const ForbiddenException( + 'You can only create engagements for your own account.', + ); + } + + // Limit Check: Delegate to the centralized service. + await userActionLimitService.checkEngagementCreationLimit( + user: authenticatedUser, + engagement: engagementToCreate, + ); + + return context.read>().create( + item: engagementToCreate, + userId: null, + ); + }, + 'report': (context, item, uid) async { + _log.info('Executing custom creator for report.'); + final authenticatedUser = context.read(); + final userActionLimitService = context.read(); + final reportToCreate = item as Report; + + // Security Check + if (reportToCreate.reporterUserId != authenticatedUser.id) { + throw const ForbiddenException( + 'You can only create reports for your own account.', + ); + } + + // Limit Check + await userActionLimitService.checkReportCreationLimit( + user: authenticatedUser, + ); + + return context.read>().create(item: item); + }, }); // --- Register Item Updaters --- @@ -398,8 +459,7 @@ class DataOperationRegistry { ); final authenticatedUser = context.read(); final permissionService = context.read(); - final userPreferenceLimitService = context - .read(); + final userActionLimitService = context.read(); final userContentPreferencesRepository = context .read>(); @@ -416,7 +476,7 @@ class DataOperationRegistry { 'User ${authenticatedUser.id} has bypass permission. Skipping limit checks.', ); } else { - await userPreferenceLimitService.checkUserContentPreferencesLimits( + await userActionLimitService.checkUserContentPreferencesLimits( user: authenticatedUser, updatedPreferences: preferencesToUpdate, ); @@ -440,6 +500,15 @@ class DataOperationRegistry { id: id, item: item as InAppNotification, ), + 'engagement': (c, id, item, uid) => + c.read>().update( + id: id, + item: item as Engagement, + ), + 'report': (c, id, item, uid) => c.read>().update( + id: id, + item: item as Report, + ), }); // --- Register Item Deleters --- @@ -467,6 +536,10 @@ class DataOperationRegistry { 'in_app_notification': (c, id, uid) => c .read>() .delete(id: id, userId: uid), + 'engagement': (c, id, uid) => + c.read>().delete(id: id, userId: uid), + 'report': (c, id, uid) => + c.read>().delete(id: id, userId: uid), }); } } diff --git a/lib/src/services/database_seeding_service.dart b/lib/src/services/database_seeding_service.dart index 2d1eff2..91345e4 100644 --- a/lib/src/services/database_seeding_service.dart +++ b/lib/src/services/database_seeding_service.dart @@ -281,6 +281,38 @@ class DatabaseSeedingService { }); _log.info('Ensured indexes for "in_app_notifications".'); + // Indexes for the engagements collection + await _db.runCommand({ + 'createIndexes': 'engagements', + 'indexes': [ + { + // Optimizes fetching all engagements for a specific user. + 'key': {'userId': 1}, + 'name': 'userId_index', + }, + { + // Optimizes fetching all engagements for a specific entity + // (e.g., a headline). + 'key': {'entityId': 1, 'entityType': 1}, + 'name': 'entity_index', + }, + ], + }); + _log.info('Ensured indexes for "engagements".'); + + // Indexes for the reports collection + await _db.runCommand({ + 'createIndexes': 'reports', + 'indexes': [ + { + // Optimizes fetching all reports submitted by a specific user. + 'key': {'reporterUserId': 1}, + 'name': 'reporterUserId_index', + }, + ], + }); + _log.info('Ensured indexes for "reports".'); + _log.info('Database indexes are set up correctly.'); } on Exception catch (e, s) { _log.severe('Failed to create database indexes.', e, s); diff --git a/lib/src/services/default_user_preference_limit_service.dart b/lib/src/services/default_user_action_limit_service.dart similarity index 65% rename from lib/src/services/default_user_preference_limit_service.dart rename to lib/src/services/default_user_action_limit_service.dart index 8b95280..ca72890 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_action_limit_service.dart @@ -1,22 +1,28 @@ import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/services/user_preference_limit_service.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/user_action_limit_service.dart'; import 'package:logging/logging.dart'; -/// {@template default_user_preference_limit_service} -/// Default implementation of [UserPreferenceLimitService] that enforces limits -/// based on user role and the `InterestConfig` and `UserPreferenceConfig` +/// {@template default_user_action_limit_service} +/// Default implementation of [UserActionLimitService] that enforces limits +/// based on user role and the `UserLimitsConfig` /// sections within the application's [RemoteConfig]. /// {@endtemplate} -class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { - /// {@macro default_user_preference_limit_service} - const DefaultUserPreferenceLimitService({ +class DefaultUserActionLimitService implements UserActionLimitService { + /// {@macro default_user_action_limit_service} + const DefaultUserActionLimitService({ required DataRepository remoteConfigRepository, + required DataRepository engagementRepository, + required DataRepository reportRepository, required Logger log, }) : _remoteConfigRepository = remoteConfigRepository, + _engagementRepository = engagementRepository, + _reportRepository = reportRepository, _log = log; final DataRepository _remoteConfigRepository; + final DataRepository _engagementRepository; + final DataRepository _reportRepository; final Logger _log; // Assuming a fixed ID for the RemoteConfig document @@ -28,7 +34,7 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { required UserContentPreferences updatedPreferences, }) async { _log.info( - 'Checking all user content preferences limits for user ${user.id}.', + 'Checking all user action limits for user ${user.id}.', ); final remoteConfig = await _remoteConfigRepository.read( id: _remoteConfigId, @@ -41,12 +47,14 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { savedHeadlinesLimit, savedHeadlineFiltersLimit, savedSourceFiltersLimit, - ) = _getLimitsForRole( + ) = _getPreferenceLimitsForRole( user.appRole, limits, ); // --- 1. Check general preference limits --- + // Note: The checks for commentsPerDay and reportsPerDay are not performed + // here. They are action-based and enforced by the RateLimitService. if (updatedPreferences.followedCountries.length > followedItemsLimit) { _log.warning( 'User ${user.id} exceeded followed countries limit: ' @@ -202,7 +210,7 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { SavedFilterLimits savedHeadlineFiltersLimit, SavedFilterLimits savedSourceFiltersLimit, ) - _getLimitsForRole( + _getPreferenceLimitsForRole( AppUserRole role, UserLimitsConfig limits, ) { @@ -237,4 +245,113 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { savedSourceFiltersLimit, ); } + + @override + Future checkEngagementCreationLimit({ + required User user, + required Engagement engagement, + }) async { + _log.info('Checking engagement creation limits for user ${user.id}.'); + final remoteConfig = await _remoteConfigRepository.read( + id: _remoteConfigId, + ); + final limits = remoteConfig.user.limits; + + // --- 1. Check Reaction Limit --- + final reactionsLimit = limits.reactionsPerDay[user.appRole]; + if (reactionsLimit == null) { + throw StateError( + 'Reactions per day limit not configured for role: ${user.appRole}', + ); + } + + // Count all engagements in the last 24 hours for the reaction limit. + final twentyFourHoursAgo = DateTime.now().subtract( + const Duration(hours: 24), + ); + final reactionCount = await _engagementRepository.count( + filter: { + 'userId': user.id, + 'createdAt': {r'$gte': twentyFourHoursAgo.toIso8601String()}, + }, + ); + + if (reactionCount >= reactionsLimit) { + _log.warning( + 'User ${user.id} exceeded reactions per day limit: $reactionsLimit.', + ); + throw const ForbiddenException( + 'You have reached your daily limit for reactions.', + ); + } + + // --- 2. Check Comment Limit (only if a comment is present) --- + if (engagement.comment != null) { + final commentsLimit = limits.commentsPerDay[user.appRole]; + if (commentsLimit == null) { + throw StateError( + 'Comments per day limit not configured for role: ${user.appRole}', + ); + } + + // Count engagements with comments in the last 24 hours. + final commentCount = await _engagementRepository.count( + filter: { + 'userId': user.id, + 'comment': {r'$exists': true, r'$ne': null}, + 'createdAt': {r'$gte': twentyFourHoursAgo.toIso8601String()}, + }, + ); + + if (commentCount >= commentsLimit) { + _log.warning( + 'User ${user.id} exceeded comments per day limit: $commentsLimit.', + ); + throw const ForbiddenException( + 'You have reached your daily limit for comments.', + ); + } + } + + _log.info( + 'Engagement creation limit checks passed for user ${user.id}.', + ); + } + + @override + Future checkReportCreationLimit({required User user}) async { + _log.info('Checking report creation limits for user ${user.id}.'); + final remoteConfig = await _remoteConfigRepository.read( + id: _remoteConfigId, + ); + final limits = remoteConfig.user.limits; + + final reportsLimit = limits.reportsPerDay[user.appRole]; + if (reportsLimit == null) { + throw StateError( + 'Reports per day limit not configured for role: ${user.appRole}', + ); + } + + final twentyFourHoursAgo = DateTime.now().subtract( + const Duration(hours: 24), + ); + final reportCount = await _reportRepository.count( + filter: { + 'reporterUserId': user.id, + 'createdAt': {r'$gte': twentyFourHoursAgo.toIso8601String()}, + }, + ); + + if (reportCount >= reportsLimit) { + _log.warning( + 'User ${user.id} exceeded reports per day limit: $reportsLimit.', + ); + throw const ForbiddenException( + 'You have reached your daily limit for reports.', + ); + } + + _log.info('Report creation limit checks passed for user ${user.id}.'); + } } diff --git a/lib/src/services/user_action_limit_service.dart b/lib/src/services/user_action_limit_service.dart new file mode 100644 index 0000000..ac184d3 --- /dev/null +++ b/lib/src/services/user_action_limit_service.dart @@ -0,0 +1,37 @@ +import 'package:core/core.dart'; + +/// {@template user_action_limit_service} +/// A service responsible for enforcing all user-related limits based on +/// the user's role and the application's remote configuration. +/// +/// This service centralizes validation for both static preference counts +/// (e.g., number of saved filters) and transactional, time-windowed actions +/// (e.g., number of engagements or reports per day). +/// {@endtemplate} +abstract class UserActionLimitService { + /// {@macro user_action_limit_service} + const UserActionLimitService(); + + /// Validates an updated [UserContentPreferences] object against all limits + /// for preference counts (e.g., followed items, saved headlines). + /// + /// Throws a [ForbiddenException] if any limit is exceeded. + Future checkUserContentPreferencesLimits({ + required User user, + required UserContentPreferences updatedPreferences, + }); + + /// Validates if a user can create a new [Engagement]. + /// + /// This method checks against `reactionsPerDay` and, if the engagement + /// contains a comment, also checks against `commentsPerDay`. + Future checkEngagementCreationLimit({ + required User user, + required Engagement engagement, + }); + + /// Validates if a user can create a new [Report]. + /// + /// This method checks against the `reportsPerDay` limit. + Future checkReportCreationLimit({required User user}); +} diff --git a/lib/src/services/user_preference_limit_service.dart b/lib/src/services/user_preference_limit_service.dart deleted file mode 100644 index affc522..0000000 --- a/lib/src/services/user_preference_limit_service.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:core/core.dart'; - -/// {@template user_preference_limit_service} -/// A service responsible for enforcing all user preference limits based on -/// the user's role and the application's remote configuration. -/// -/// This service centralizes validation for both the `Interest` model and -/// the `UserContentPreferences` model (e.g., followed items, saved headlines). -/// {@endtemplate} -abstract class UserPreferenceLimitService { - /// {@macro user_preference_limit_service} - const UserPreferenceLimitService(); - - /// Validates an updated [UserContentPreferences] object against all limits - /// defined in `RemoteConfig`, including interests, followed items, and - /// saved headlines. - /// - /// Throws a [ForbiddenException] if any limit is exceeded. - Future checkUserContentPreferencesLimits({ - required User user, - required UserContentPreferences updatedPreferences, - }); -} diff --git a/pubspec.lock b/pubspec.lock index 21a1723..9bf3a80 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -181,8 +181,8 @@ packages: dependency: "direct main" description: path: "." - ref: "3779a8b1dbd8450d524574cf5376b7cc2ed514e7" - resolved-ref: "3779a8b1dbd8450d524574cf5376b7cc2ed514e7" + ref: "4ffe8833b8a042b2b3b68550f682c5aa902a5ccc" + resolved-ref: "4ffe8833b8a042b2b3b68550f682c5aa902a5ccc" url: "https://github.com/flutter-news-app-full-source-code/core.git" source: git version: "1.3.1" diff --git a/pubspec.yaml b/pubspec.yaml index 474c40e..c2e3790 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,7 +62,7 @@ dependency_overrides: core: git: url: https://github.com/flutter-news-app-full-source-code/core.git - ref: 3779a8b1dbd8450d524574cf5376b7cc2ed514e7 + ref: 4ffe8833b8a042b2b3b68550f682c5aa902a5ccc http_client: git: url: https://github.com/flutter-news-app-full-source-code/http-client.git diff --git a/routes/_middleware.dart b/routes/_middleware.dart index 21607b8..b09a675 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -16,7 +16,7 @@ import 'package:flutter_news_app_api_server_full_source_code/src/services/fireba import 'package:flutter_news_app_api_server_full_source_code/src/services/push_notification_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/rate_limit_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/token_blacklist_service.dart'; -import 'package:flutter_news_app_api_server_full_source_code/src/services/user_preference_limit_service.dart'; +import 'package:flutter_news_app_api_server_full_source_code/src/services/user_action_limit_service.dart'; import 'package:flutter_news_app_api_server_full_source_code/src/services/verification_code_storage_service.dart'; import 'package:logging/logging.dart'; import 'package:mongo_dart/mongo_dart.dart'; @@ -169,8 +169,8 @@ Handler middleware(Handler handler) { ) .use(provider((_) => deps.permissionService)) .use( - provider( - (_) => deps.userPreferenceLimitService, + provider( + (_) => deps.userActionLimitService, ), ) .use(provider((_) => deps.rateLimitService))