diff --git a/Sources/VernissageServer/Application+Configure.swift b/Sources/VernissageServer/Application+Configure.swift index b561228..94d06be 100644 --- a/Sources/VernissageServer/Application+Configure.swift +++ b/Sources/VernissageServer/Application+Configure.swift @@ -298,6 +298,7 @@ extension Application { self.migrations.add(User.AddUrl()) self.migrations.add(Exif.AddGpsCoordinates()) self.migrations.add(Exif.AddSoftware()) + self.migrations.add(FeaturedUser.CreateFeaturedUsers()) try await self.autoMigrate() } diff --git a/Sources/VernissageServer/Controllers/InstanceController.swift b/Sources/VernissageServer/Controllers/InstanceController.swift index 02ab24c..4464f57 100644 --- a/Sources/VernissageServer/Controllers/InstanceController.swift +++ b/Sources/VernissageServer/Controllers/InstanceController.swift @@ -149,17 +149,12 @@ struct InstanceController { return nil } - guard let user = try await User.query(on: request.db).filter(\.$id == contactUserId).first() else { + let usersService = request.application.services.usersService + guard let user = try await usersService.get(on: request.db, id: contactUserId) else { return nil } - - let baseStoragePath = request.application.services.storageService.getBaseStoragePath(on: request.application) - let baseAddress = request.application.settings.cached?.baseAddress ?? "" - - var userDto = UserDto(from: user, baseStoragePath: baseStoragePath, baseAddress: baseAddress) - userDto.email = nil - userDto.locale = nil - + + let userDto = await usersService.convertToDto(on: request, user: user, flexiFields: user.flexiFields, roles: nil, attachSensitive: false) return userDto } } diff --git a/Sources/VernissageServer/Controllers/NotificationsController.swift b/Sources/VernissageServer/Controllers/NotificationsController.swift index dfeebf9..b314a2a 100644 --- a/Sources/VernissageServer/Controllers/NotificationsController.swift +++ b/Sources/VernissageServer/Controllers/NotificationsController.swift @@ -96,14 +96,14 @@ struct NotificationsController { let linkableParams = request.linkableParams() let notificationsService = request.application.services.notificationsService - let notifications = try await notificationsService.list(on: request.db, for: authorizationPayloadId, linkableParams: linkableParams) - - let baseStoragePath = request.application.services.storageService.getBaseStoragePath(on: request.application) - let baseAddress = request.application.settings.cached?.baseAddress ?? "" + let usersService = request.application.services.usersService + let notifications = try await notificationsService.list(on: request.db, for: authorizationPayloadId, linkableParams: linkableParams) + let notificationDtos = await notifications.asyncMap({ let notificationTypeDto = NotificationTypeDto.from($0.notificationType) - let user = UserDto(from: $0.byUser, baseStoragePath: baseStoragePath, baseAddress: baseAddress) + + let user = await usersService.convertToDto(on: request, user: $0.byUser, flexiFields: $0.byUser.flexiFields, roles: nil, attachSensitive: false) let status = await self.getStatus($0.status, on: request) return NotificationDto(id: $0.stringId(), notificationType: notificationTypeDto, byUser: user, status: status) diff --git a/Sources/VernissageServer/Controllers/RegisterController.swift b/Sources/VernissageServer/Controllers/RegisterController.swift index a6a6725..d82606a 100644 --- a/Sources/VernissageServer/Controllers/RegisterController.swift +++ b/Sources/VernissageServer/Controllers/RegisterController.swift @@ -337,13 +337,8 @@ struct RegisterController { } private func createNewUserResponse(on request: Request, user: User, flexiFields: [FlexiField]) async throws -> Response { - let baseStoragePath = request.application.services.storageService.getBaseStoragePath(on: request.application) - let baseAddress = request.application.settings.cached?.baseAddress ?? "" - - var createdUserDto = UserDto(from: user, flexiFields: flexiFields, baseStoragePath: baseStoragePath, baseAddress: baseAddress) - createdUserDto.email = user.email - createdUserDto.emailWasConfirmed = user.emailWasConfirmed - createdUserDto.locale = user.locale + let usersService = request.application.services.usersService + let createdUserDto = await usersService.convertToDto(on: request, user: user, flexiFields: user.flexiFields, roles: nil, attachSensitive: true) var headers = HTTPHeaders() headers.replaceOrAdd(name: .location, value: "/\(UsersController.uri)/@\(user.userName)") diff --git a/Sources/VernissageServer/Controllers/SettingsController.swift b/Sources/VernissageServer/Controllers/SettingsController.swift index c9082b7..2691253 100644 --- a/Sources/VernissageServer/Controllers/SettingsController.swift +++ b/Sources/VernissageServer/Controllers/SettingsController.swift @@ -171,6 +171,7 @@ struct SettingsController { showLocalTimelineForAnonymous: settings.showLocalTimelineForAnonymous, showTrendingForAnonymous: settings.showTrendingForAnonymous, showEditorsChoiceForAnonymous: settings.showEditorsChoiceForAnonymous, + showEditorsUsersChoiceForAnonymous: settings.showEditorsUsersChoiceForAnonymous, showHashtagsForAnonymous: settings.showHashtagsForAnonymous, showCategoriesForAnonymous: settings.showCategoriesForAnonymous) @@ -526,6 +527,13 @@ struct SettingsController { transaction: database) } + if settingsDto.showEditorsUsersChoiceForAnonymous != settings.getBool(.showEditorsUsersChoiceForAnonymous) { + try await self.update(.showEditorsUsersChoiceForAnonymous, + with: .boolean(settingsDto.showEditorsUsersChoiceForAnonymous), + on: request, + transaction: database) + } + if settingsDto.showHashtagsForAnonymous != settings.getBool(.showHashtagsForAnonymous) { try await self.update(.showHashtagsForAnonymous, with: .boolean(settingsDto.showHashtagsForAnonymous), diff --git a/Sources/VernissageServer/Controllers/StatusesController.swift b/Sources/VernissageServer/Controllers/StatusesController.swift index ee017a3..ecd86e4 100644 --- a/Sources/VernissageServer/Controllers/StatusesController.swift +++ b/Sources/VernissageServer/Controllers/StatusesController.swift @@ -1342,14 +1342,9 @@ struct StatusesController { let statusesService = request.application.services.statusesService let linkableUsers = try await statusesService.reblogged(on: request, statusId: statusId, linkableParams: linkableParams) - let baseStoragePath = request.application.services.storageService.getBaseStoragePath(on: request.application) - let baseAddress = request.application.settings.cached?.baseAddress ?? "" - - let userProfiles = try await linkableUsers.data.asyncMap { user in - let flexiFields = try await user.$flexiFields.get(on: request.db) - return UserDto(from: user, flexiFields: flexiFields, baseStoragePath: baseStoragePath, baseAddress: baseAddress) - } - + let usersService = request.application.services.usersService + let userProfiles = await usersService.convertToDtos(on: request, users: linkableUsers.data, attachSensitive: false) + return LinkableResultDto( maxId: linkableUsers.maxId, minId: linkableUsers.minId, @@ -1679,13 +1674,8 @@ struct StatusesController { let statusesService = request.application.services.statusesService let linkableUsers = try await statusesService.favourited(on: request, statusId: statusId, linkableParams: linkableParams) - let baseStoragePath = request.application.services.storageService.getBaseStoragePath(on: request.application) - let baseAddress = request.application.settings.cached?.baseAddress ?? "" - - let userProfiles = try await linkableUsers.data.asyncMap { user in - let flexiFields = try await user.$flexiFields.get(on: request.db) - return UserDto(from: user, flexiFields: flexiFields, baseStoragePath: baseStoragePath, baseAddress: baseAddress) - } + let usersService = request.application.services.usersService + let userProfiles = await usersService.convertToDtos(on: request, users: linkableUsers.data, attachSensitive: false) return LinkableResultDto( maxId: linkableUsers.maxId, diff --git a/Sources/VernissageServer/Controllers/TimelinesController.swift b/Sources/VernissageServer/Controllers/TimelinesController.swift index c2bfb34..f7cd972 100644 --- a/Sources/VernissageServer/Controllers/TimelinesController.swift +++ b/Sources/VernissageServer/Controllers/TimelinesController.swift @@ -33,8 +33,12 @@ extension TimelinesController: RouteCollection { .get("hashtag", ":hashtag", use: hashtag) timelinesGroup - .grouped(EventHandlerMiddleware(.timelinesFeatured)) - .get("featured", use: featured) + .grouped(EventHandlerMiddleware(.timelinesFeaturedStatuses)) + .get("featured-statuses", use: featuredStatuses) + + timelinesGroup + .grouped(EventHandlerMiddleware(.timelinesFeaturedUsers)) + .get("featured-users", use: featuredUsers) timelinesGroup .grouped(UserPayload.guardMiddleware()) @@ -474,8 +478,7 @@ struct TimelinesController { /// Exposing featured timeline. You can set in the settings if the timeline should be visible for anonymous users. /// - /// This is an endpoint that returns a list of statuses that have been - /// to data to a special list of statuses listed by moderators. + /// This is an endpoint that returns a list of statuses that have been featured by moderators/administrators. /// /// Optional query params: /// - `onlyLocal` - `true` if list should contain only statuses added on local instance @@ -484,12 +487,12 @@ struct TimelinesController { /// - `sinceId` - return latest entites since entity /// - `limit` - limit amount of returned entities (default: 40) /// - /// > Important: Endpoint URL: `/api/v1/timelines/featured`. + /// > Important: Endpoint URL: `/api/v1/timelines/featured-statuses`. /// /// **CURL request:** /// /// ```bash - /// curl "https://example.com/api/v1/timelines/featured" \ + /// curl "https://example.com/api/v1/timelines/featured-statuses" \ /// -X GET \ /// -H "Content-Type: application/json" \ /// -H "Authorization: Bearer [ACCESS_TOKEN]" \ @@ -585,17 +588,17 @@ struct TimelinesController { /// /// - Returns: List of linkable statuses. @Sendable - func featured(request: Request) async throws -> LinkableResultDto { + func featuredStatuses(request: Request) async throws -> LinkableResultDto { let appplicationSettings = request.application.settings.cached if request.userId == nil && appplicationSettings?.showEditorsChoiceForAnonymous == false { - throw ActionsForbiddenError.editorsChoiceForbidden + throw ActionsForbiddenError.editorsStatusesChoiceForbidden } let onlyLocal: Bool = request.query["onlyLocal"] ?? false let linkableParams = request.linkableParams() let timelineService = request.application.services.timelineService - let statuses = try await timelineService.featured(on: request.db, linkableParams: linkableParams, onlyLocal: onlyLocal) + let statuses = try await timelineService.featuredStatuses(on: request.db, linkableParams: linkableParams, onlyLocal: onlyLocal) let statusesService = request.application.services.statusesService let statusDtos = await statusesService.convertToDtos(on: request, statuses: statuses.data) @@ -607,6 +610,101 @@ struct TimelinesController { ) } + /// Exposing featured users. You can set in the settings if the timeline should be visible for anonymous users. + /// + /// This is an endpoint that returns a list of users that have been featured by moderators/administrators. + /// + /// Optional query params: + /// - `onlyLocal` - `true` if list should contain only users added on local instance + /// - `minId` - return only newest entities + /// - `maxId` - return only oldest entities + /// - `sinceId` - return latest entites since entity + /// - `limit` - limit amount of returned entities (default: 40) + /// + /// > Important: Endpoint URL: `/api/v1/timelines/featured-users`. + /// + /// **CURL request:** + /// + /// ```bash + /// curl "https://example.com/api/v1/timelines/featured-users" \ + /// -X GET \ + /// -H "Content-Type: application/json" \ + /// -H "Authorization: Bearer [ACCESS_TOKEN]" \ + /// ``` + /// + /// **Example response body:** + /// + /// ```json + /// { + /// "data": [ + /// { + /// "account": "johndoe@example.com", + /// "activityPubProfile": "https://example.com/users/johndoe", + /// "avatarUrl": "https://example.com/09267580898c4d3abfc5871bbdb4483e.jpeg", + /// "bio": "

Landscape, nature and fine-art photographer

", + /// "bioHtml": "

Landscape, nature and fine-art photographer

", + /// "createdAt": "2023-08-16T15:13:08.607Z", + /// "fields": [], + /// "followersCount": 0, + /// "followingCount": 0, + /// "headerUrl": "https://example.com/700049efc6c04068a3634317e1f95e32.jpg", + /// "id": "7267938074834522113", + /// "isLocal": false, + /// "name": "John Doe", + /// "statusesCount": 0, + /// "updatedAt": "2024-02-09T05:12:23.479Z", + /// "userName": "johndoe@example.com" + /// }, + /// { + /// "account": "lindadoe@example.com", + /// "activityPubProfile": "https://example.com/users/lindadoe", + /// "avatarUrl": "https://example.com/44debf8889d74b5a9be651f575a3651c.jpg", + /// "bio": "

Landscape, nature and street photographer

", + /// "bioHtml": "

Landscape, nature and street photographer

", + /// "createdAt": "2024-02-07T10:25:36.538Z", + /// "fields": [], + /// "followersCount": 0, + /// "followingCount": 0, + /// "id": "7332804261530576897", + /// "isLocal": false, + /// "name": "Linda Doe", + /// "statusesCount": 0, + /// "updatedAt": "2024-02-07T10:25:36.538Z", + /// "userName": "lindadoe@example.com" + /// } + /// ], + /// "maxId": "7333853122610761729", + /// "minId": "7333853122610761729" + /// } + /// ``` + /// + /// - Parameters: + /// - request: The Vapor request to the endpoint. + /// + /// - Returns: List of linkable users. + @Sendable + func featuredUsers(request: Request) async throws -> LinkableResultDto { + let appplicationSettings = request.application.settings.cached + if request.userId == nil && appplicationSettings?.showEditorsUsersChoiceForAnonymous == false { + throw ActionsForbiddenError.editorsUsersChoiceForbidden + } + + let onlyLocal: Bool = request.query["onlyLocal"] ?? false + let linkableParams = request.linkableParams() + + let timelineService = request.application.services.timelineService + let users = try await timelineService.featuredUsers(on: request.db, linkableParams: linkableParams, onlyLocal: onlyLocal) + + let usersService = request.application.services.usersService + let userDtos = await usersService.convertToDtos(on: request, users: users.data, attachSensitive: false) + + return LinkableResultDto( + maxId: users.maxId, + minId: users.minId, + data: userDtos + ) + } + /// Exposing home timeline. /// /// This is the endpoint that is most important to the logged-in user. diff --git a/Sources/VernissageServer/Controllers/TrendingController.swift b/Sources/VernissageServer/Controllers/TrendingController.swift index e741d38..9d7b5d5 100644 --- a/Sources/VernissageServer/Controllers/TrendingController.swift +++ b/Sources/VernissageServer/Controllers/TrendingController.swift @@ -257,14 +257,11 @@ struct TrendingController { let linkableParams = request.linkableParams() let trendingService = request.application.services.trendingService - let baseStoragePath = request.application.services.storageService.getBaseStoragePath(on: request.application) - let baseAddress = request.application.settings.cached?.baseAddress ?? "" - let trending = try await trendingService.users(on: request.db, linkableParams: linkableParams, period: period.translate()) - let userDtos = await trending.data.asyncMap({ - UserDto(from: $0, flexiFields: $0.flexiFields, baseStoragePath: baseStoragePath, baseAddress: baseAddress) - }) + let usersService = request.application.services.usersService + let userDtos = await usersService.convertToDtos(on: request, users: trending.data, attachSensitive: false) + return LinkableResultDto( maxId: trending.maxId, minId: trending.minId, diff --git a/Sources/VernissageServer/Controllers/UsersController.swift b/Sources/VernissageServer/Controllers/UsersController.swift index 6279871..17c932f 100644 --- a/Sources/VernissageServer/Controllers/UsersController.swift +++ b/Sources/VernissageServer/Controllers/UsersController.swift @@ -116,6 +116,20 @@ extension UsersController: RouteCollection { .grouped(EventHandlerMiddleware(.userApprove)) .post(":name", "reject", use: reject) + usersGroup + .grouped(UserPayload.guardMiddleware()) + .grouped(UserPayload.guardIsModeratorMiddleware()) + .grouped(XsrfTokenValidatorMiddleware()) + .grouped(EventHandlerMiddleware(.userFeature)) + .post(":name", "feature", use: feature) + + usersGroup + .grouped(UserPayload.guardMiddleware()) + .grouped(UserPayload.guardIsModeratorMiddleware()) + .grouped(XsrfTokenValidatorMiddleware()) + .grouped(EventHandlerMiddleware(.userUnfeature)) + .post(":name", "unfeature", use: unfeature) + usersGroup .grouped(UserPayload.guardMiddleware()) .grouped(UserPayload.guardIsModeratorMiddleware()) @@ -211,9 +225,6 @@ struct UsersController { /// - Returns: List of paginable users. @Sendable func list(request: Request) async throws -> PaginableResultDto { - let baseStoragePath = request.application.services.storageService.getBaseStoragePath(on: request.application) - let baseAddress = request.application.settings.cached?.baseAddress ?? "" - let page: Int = request.query["page"] ?? 0 let size: Int = request.query["size"] ?? 10 let query: String? = request.query["query"] ?? nil @@ -235,17 +246,9 @@ struct UsersController { .sort(\.$createdAt, .descending) .paginate(PageRequest(page: page, per: size)) - let userDtos = await usersFromDatabase.items.asyncMap({ - var userDto = UserDto(from: $0, flexiFields: $0.flexiFields, roles: $0.roles, baseStoragePath: baseStoragePath, baseAddress: baseAddress) - userDto.email = $0.email - userDto.emailWasConfirmed = $0.emailWasConfirmed - userDto.locale = $0.locale - userDto.isBlocked = $0.isBlocked - userDto.isApproved = $0.isApproved - - return userDto - }) - + let usersService = request.application.services.usersService + let userDtos = await usersService.convertToDtos(on: request, users: usersFromDatabase.items, attachSensitive: true) + return PaginableResultDto( data: userDtos, page: usersFromDatabase.metadata.page, @@ -314,12 +317,14 @@ struct UsersController { throw EntityNotFoundError.userNotFound } - let flexiFields = try await user.$flexiFields.get(on: request.db) - let userProfile = self.getUserProfile(on: request, - user: user, - flexiFields: flexiFields, - userNameFromRequest: userNameNormalized) + let userNameFromToken = request.auth.get(UserPayload.self)?.userName + let isProfileOwner = userNameFromToken?.uppercased() == userNameNormalized + let userProfile = await usersService.convertToDto(on: request, + user: user, + flexiFields: user.flexiFields, + roles: nil, + attachSensitive: isProfileOwner) return userProfile } @@ -453,17 +458,12 @@ struct UsersController { // Enqueue job for flexi field URL validator. try await flexiFieldService.dispatchUrlValidator(on: request, flexiFields: flexiFields) - - let baseStoragePath = request.application.services.storageService.getBaseStoragePath(on: request.application) - let baseAddress = request.application.settings.cached?.baseAddress ?? "" - - var userDtoAfterUpdate = UserDto(from: user, flexiFields: flexiFields, baseStoragePath: baseStoragePath, baseAddress: baseAddress) - userDtoAfterUpdate.email = user.email - userDtoAfterUpdate.emailWasConfirmed = user.emailWasConfirmed - userDtoAfterUpdate.locale = user.locale - userDtoAfterUpdate.twoFactorEnabled = user.twoFactorEnabled - userDtoAfterUpdate.manuallyApprovesFollowers = user.manuallyApprovesFollowers - + + let userDtoAfterUpdate = await usersService.convertToDto(on: request, + user: user, + flexiFields: flexiFields, + roles: nil, + attachSensitive: true) return userDtoAfterUpdate } @@ -803,15 +803,7 @@ struct UsersController { } let linkableUsers = try await followsService.follows(on: request, targetId: user.requireID(), onlyApproved: false, linkableParams: linkableParams) - - let userProfiles = try await linkableUsers.data.asyncMap { user in - let flexiFields = try await user.$flexiFields.get(on: request.db) - let userProfile = self.getUserProfile(on: request, - user: user, - flexiFields: flexiFields, - userNameFromRequest: userNameNormalized) - return userProfile - } + let userProfiles = await usersService.convertToDtos(on: request, users: linkableUsers.data, attachSensitive: false) return LinkableResultDto( maxId: linkableUsers.maxId, @@ -892,16 +884,8 @@ struct UsersController { } let linkableUsers = try await followsService.following(on: request, sourceId: user.requireID(), onlyApproved: false, linkableParams: linkableParams) - - let userProfiles = try await linkableUsers.data.asyncMap { user in - let flexiFields = try await user.$flexiFields.get(on: request.db) - let userProfile = self.getUserProfile(on: request, - user: user, - flexiFields: flexiFields, - userNameFromRequest: userNameNormalized) - return userProfile - } - + let userProfiles = await usersService.convertToDtos(on: request, users: linkableUsers.data, attachSensitive: false) + return LinkableResultDto( maxId: linkableUsers.maxId, minId: linkableUsers.minId, @@ -1520,26 +1504,201 @@ struct UsersController { } } - private func getUserProfile(on request: Request, user: User, flexiFields: [FlexiField], userNameFromRequest: String) -> UserDto { - let baseStoragePath = request.application.services.storageService.getBaseStoragePath(on: request.application) - let baseAddress = request.application.settings.cached?.baseAddress ?? "" - - var userDto = UserDto(from: user, flexiFields: flexiFields, baseStoragePath: baseStoragePath, baseAddress: baseAddress) - let userNameFromToken = request.auth.get(UserPayload.self)?.userName - let isProfileOwner = userNameFromToken?.uppercased() == userNameFromRequest + + + + + /// Feature specific user. + /// + /// This endpoint is used to add the user to a special list of featured users. + /// Only moderators and administrators have access to this endpoint. + /// + /// > Important: Endpoint URL: `/api/v1/users/:userName/feature`. + /// + /// **CURL request:** + /// + /// ```bash + /// curl "https://example.com/api/v1/users/@johndoe/feature" \ + /// -X POST \ + /// -H "Content-Type: application/json" \ + /// -H "Authorization: Bearer [ACCESS_TOKEN]" \ + /// ``` + /// + /// **Example response body:** + /// + /// ```json + /// { + /// "account": "johndoe@example.com", + /// "activityPubProfile": "https://example.com/users/johndoe", + /// "avatarUrl": "https://example.com/cd743f07793747daa7d9aa7662b78f7a.jpeg", + /// "bio": "

This is a bio.

", + /// "bioHtml": "

", + /// "createdAt": "2023-07-27T15:39:47.627Z", + /// "fields": [], + /// "followersCount": 1, + /// "followingCount": 1, + /// "headerUrl": "https://example.com/ab01b3185a82430788016f4072d5d81b.jpg", + /// "id": "7260522736489424897", + /// "isLocal": false, + /// "name": "John Doe", + /// "statusesCount": 0, + /// "updatedAt": "2024-02-09T05:12:22.711Z", + /// "userName": "johndoe@example.com" + /// } + /// ``` + /// + /// - Parameters: + /// - request: The Vapor request to the endpoint. + /// + /// - Returns: Information about featured user. + /// + /// - Throws: `StatusError.incorrectStatusId` if status id is incorrect. + /// - Throws: `EntityNotFoundError.statusNotFound` if status not exists. + @Sendable + func feature(request: Request) async throws -> UserDto { + let usersService = request.application.services.usersService - if isProfileOwner { - userDto.email = user.email - userDto.locale = user.locale - userDto.emailWasConfirmed = user.emailWasConfirmed - userDto.twoFactorEnabled = user.twoFactorEnabled - userDto.manuallyApprovesFollowers = user.manuallyApprovesFollowers + guard let authorizationPayloadId = request.userId else { + throw Abort(.forbidden) + } + + guard let userName = request.parameters.get("name") else { + throw Abort(.badRequest) } + + let userNameNormalized = userName.deletingPrefix("@").uppercased() + guard let user = try await usersService.get(on: request.db, userName: userNameNormalized) else { + throw EntityNotFoundError.userNotFound + } + + guard let userId = try? user.requireID() else { + throw EntityNotFoundError.userNotFound + } + + guard let _ = try await usersService.get(on: request.db, userName: userNameNormalized) else { + throw EntityNotFoundError.userNotFound + } + + if try await FeaturedUser.query(on: request.db) + .filter(\.$user.$id == authorizationPayloadId) + .filter(\.$featuredUser.$id == userId) + .first() == nil { + let id = request.application.services.snowflakeService.generate() + let featuredUser = FeaturedUser(id: id, featuredUserId: userId, userId: authorizationPayloadId) + try await featuredUser.save(on: request.db) + } + + // Prepare and return user. + let userFromDatabaseAfterFeature = try await usersService.get(on: request.db, id: userId) + guard let userFromDatabaseAfterFeature else { + throw EntityNotFoundError.statusNotFound + } + + let userProfile = await usersService.convertToDto(on: request, + user: userFromDatabaseAfterFeature, + flexiFields: userFromDatabaseAfterFeature.flexiFields, + roles: nil, + attachSensitive: false) + return userProfile + } + + /// Unfeature specific user. + /// + /// This endpoint is used to delete the user from a special list of featured users. + /// Only moderators and administrators have access to this endpoint. + /// + /// > Important: Endpoint URL: `/api/v1/users/:userName/unfeature`. + /// + /// **CURL request:** + /// + /// ```bash + /// curl "https://example.com/api/v1/users/@johndoe/unfeature" \ + /// -X POST \ + /// -H "Content-Type: application/json" \ + /// -H "Authorization: Bearer [ACCESS_TOKEN]" \ + /// ``` + /// + /// **Example response body:** + /// + /// ```json + /// { + /// "account": "johndoe@example.com", + /// "activityPubProfile": "https://example.com/users/johndoe", + /// "avatarUrl": "https://example.com/cd743f07793747daa7d9aa7662b78f7a.jpeg", + /// "bio": "

This is a bio.

", + /// "bioHtml": "

", + /// "createdAt": "2023-07-27T15:39:47.627Z", + /// "fields": [], + /// "followersCount": 1, + /// "followingCount": 1, + /// "headerUrl": "https://example.com/ab01b3185a82430788016f4072d5d81b.jpg", + /// "id": "7260522736489424897", + /// "isLocal": false, + /// "name": "John Doe", + /// "statusesCount": 0, + /// "updatedAt": "2024-02-09T05:12:22.711Z", + /// "userName": "johndoe@example.com" + /// } + /// ``` + /// + /// - Parameters: + /// - request: The Vapor request to the endpoint. + /// + /// - Returns: Information about status. + /// + /// - Throws: `StatusError.incorrectStatusId` if status id is incorrect. + /// - Throws: `EntityNotFoundError.statusNotFound` if status not exists. + @Sendable + func unfeature(request: Request) async throws -> UserDto { + let usersService = request.application.services.usersService - return userDto + guard let authorizationPayloadId = request.userId else { + throw Abort(.forbidden) + } + + guard let userName = request.parameters.get("name") else { + throw Abort(.badRequest) + } + + let userNameNormalized = userName.deletingPrefix("@").uppercased() + guard let user = try await usersService.get(on: request.db, userName: userNameNormalized) else { + throw EntityNotFoundError.userNotFound + } + + guard let userId = try? user.requireID() else { + throw EntityNotFoundError.userNotFound + } + + guard let _ = try await usersService.get(on: request.db, userName: userNameNormalized) else { + throw EntityNotFoundError.userNotFound + } + + if let featureUser = try await FeaturedUser.query(on: request.db) + .filter(\.$user.$id == authorizationPayloadId) + .filter(\.$featuredUser.$id == userId) + .first() { + try await featureUser.delete(on: request.db) + } + + // Prepare and return user. + let userFromDatabaseAfterFeature = try await usersService.get(on: request.db, id: userId) + guard let userFromDatabaseAfterFeature else { + throw EntityNotFoundError.statusNotFound + } + + let userProfile = await usersService.convertToDto(on: request, + user: userFromDatabaseAfterFeature, + flexiFields: userFromDatabaseAfterFeature.flexiFields, + roles: nil, + attachSensitive: false) + return userProfile } + + + + private func relationship(on request: Request, sourceId: Int64, targetUser: User) async throws -> RelationshipDto { let targetUserId = try targetUser.requireID() let relationshipsService = request.application.services.relationshipsService diff --git a/Sources/VernissageServer/DataTransferObjects/PublicSettingsDto.swift b/Sources/VernissageServer/DataTransferObjects/PublicSettingsDto.swift index 0501012..ab772e3 100644 --- a/Sources/VernissageServer/DataTransferObjects/PublicSettingsDto.swift +++ b/Sources/VernissageServer/DataTransferObjects/PublicSettingsDto.swift @@ -20,6 +20,7 @@ struct PublicSettingsDto { let showLocalTimelineForAnonymous: Bool let showTrendingForAnonymous: Bool let showEditorsChoiceForAnonymous: Bool + let showEditorsUsersChoiceForAnonymous: Bool let showHashtagsForAnonymous: Bool let showCategoriesForAnonymous: Bool } diff --git a/Sources/VernissageServer/DataTransferObjects/SettingsDto.swift b/Sources/VernissageServer/DataTransferObjects/SettingsDto.swift index 04b3db2..2919648 100644 --- a/Sources/VernissageServer/DataTransferObjects/SettingsDto.swift +++ b/Sources/VernissageServer/DataTransferObjects/SettingsDto.swift @@ -57,6 +57,7 @@ struct SettingsDto { let showLocalTimelineForAnonymous: Bool let showTrendingForAnonymous: Bool let showEditorsChoiceForAnonymous: Bool + let showEditorsUsersChoiceForAnonymous: Bool let showHashtagsForAnonymous: Bool let showCategoriesForAnonymous: Bool @@ -112,6 +113,7 @@ struct SettingsDto { self.showLocalTimelineForAnonymous = settings.getBool(.showLocalTimelineForAnonymous) ?? false self.showTrendingForAnonymous = settings.getBool(.showTrendingForAnonymous) ?? false self.showEditorsChoiceForAnonymous = settings.getBool(.showEditorsChoiceForAnonymous) ?? false + self.showEditorsUsersChoiceForAnonymous = settings.getBool(.showEditorsUsersChoiceForAnonymous) ?? false self.showHashtagsForAnonymous = settings.getBool(.showHashtagsForAnonymous) ?? false self.showCategoriesForAnonymous = settings.getBool(.showCategoriesForAnonymous) ?? false } diff --git a/Sources/VernissageServer/DataTransferObjects/UserDto.swift b/Sources/VernissageServer/DataTransferObjects/UserDto.swift index 31c305d..6198653 100644 --- a/Sources/VernissageServer/DataTransferObjects/UserDto.swift +++ b/Sources/VernissageServer/DataTransferObjects/UserDto.swift @@ -32,6 +32,7 @@ struct UserDto: Codable { var roles: [String]? var twoFactorEnabled: Bool? var manuallyApprovesFollowers: Bool? + var featured: Bool enum CodingKeys: String, CodingKey { case id @@ -59,6 +60,7 @@ struct UserDto: Codable { case roles case twoFactorEnabled case manuallyApprovesFollowers + case featured } init(id: String? = nil, @@ -82,7 +84,8 @@ struct UserDto: Codable { roles: [String]? = nil, createdAt: Date? = nil, updatedAt: Date? = nil, - baseAddress: String) { + baseAddress: String, + featured: Bool = false) { self.id = id self.url = url self.isLocal = isLocal @@ -109,6 +112,8 @@ struct UserDto: Codable { self.email = nil self.emailWasConfirmed = nil self.locale = nil + + self.featured = featured } init(from decoder: Decoder) throws { @@ -137,6 +142,7 @@ struct UserDto: Codable { roles = try values.decodeIfPresent([String].self, forKey: .roles) twoFactorEnabled = try values.decodeIfPresent(Bool.self, forKey: .twoFactorEnabled) ?? false manuallyApprovesFollowers = try values.decodeIfPresent(Bool.self, forKey: .manuallyApprovesFollowers) ?? false + featured = try values.decodeIfPresent(Bool.self, forKey: .featured) ?? false } func encode(to encoder: Encoder) throws { @@ -166,11 +172,12 @@ struct UserDto: Codable { try container.encodeIfPresent(roles, forKey: .roles) try container.encodeIfPresent(twoFactorEnabled, forKey: .twoFactorEnabled) try container.encodeIfPresent(manuallyApprovesFollowers, forKey: .manuallyApprovesFollowers) + try container.encodeIfPresent(featured, forKey: .featured) } } extension UserDto { - init(from user: User, flexiFields: [FlexiField]? = nil, roles: [Role]? = nil, baseStoragePath: String, baseAddress: String) { + init(from user: User, flexiFields: [FlexiField]? = nil, roles: [Role]? = nil, baseStoragePath: String, baseAddress: String, featured: Bool = false) { let avatarUrl = UserDto.getAvatarUrl(user: user, baseStoragePath: baseStoragePath) let headerUrl = UserDto.getHeaderUrl(user: user, baseStoragePath: baseStoragePath) @@ -192,7 +199,8 @@ extension UserDto { roles: roles?.map({ $0.code }), createdAt: user.createdAt, updatedAt: user.updatedAt, - baseAddress: baseAddress) + baseAddress: baseAddress, + featured: featured) } private static func getAvatarUrl(user: User, baseStoragePath: String) -> String? { diff --git a/Sources/VernissageServer/Errors/ActionsForbiddenError.swift b/Sources/VernissageServer/Errors/ActionsForbiddenError.swift index 9ead35a..5440d00 100644 --- a/Sources/VernissageServer/Errors/ActionsForbiddenError.swift +++ b/Sources/VernissageServer/Errors/ActionsForbiddenError.swift @@ -11,7 +11,8 @@ import ExtendedError enum ActionsForbiddenError: String, Error { case localTimelineForbidden case trendingForbidden - case editorsChoiceForbidden + case editorsStatusesChoiceForbidden + case editorsUsersChoiceForbidden case hashtagsForbidden case categoriesForbidden } @@ -25,7 +26,8 @@ extension ActionsForbiddenError: LocalizedTerminateError { switch self { case .localTimelineForbidden: return "Access to local timeline is forbidden." case .trendingForbidden: return "Access to trending is forbidden." - case .editorsChoiceForbidden: return "Access to editor's choice is forbidden." + case .editorsStatusesChoiceForbidden: return "Access to editor's statuses choice is forbidden." + case .editorsUsersChoiceForbidden: return "Access to editor's users choice is forbidden." case .hashtagsForbidden: return "Access to hashtags is forbidden." case .categoriesForbidden: return "Access to categories is forbidden." } diff --git a/Sources/VernissageServer/Extensions/Application+Seed.swift b/Sources/VernissageServer/Extensions/Application+Seed.swift index 5b00302..3c2ecf6 100644 --- a/Sources/VernissageServer/Extensions/Application+Seed.swift +++ b/Sources/VernissageServer/Extensions/Application+Seed.swift @@ -95,6 +95,7 @@ extension Application { try await ensureSettingExists(on: database, existing: settings, key: .showLocalTimelineForAnonymous, value: .boolean(true)) try await ensureSettingExists(on: database, existing: settings, key: .showTrendingForAnonymous, value: .boolean(false)) try await ensureSettingExists(on: database, existing: settings, key: .showEditorsChoiceForAnonymous, value: .boolean(false)) + try await ensureSettingExists(on: database, existing: settings, key: .showEditorsUsersChoiceForAnonymous, value: .boolean(false)) try await ensureSettingExists(on: database, existing: settings, key: .showHashtagsForAnonymous, value: .boolean(false)) try await ensureSettingExists(on: database, existing: settings, key: .showCategoriesForAnonymous, value: .boolean(false)) } diff --git a/Sources/VernissageServer/Migrations/CreateFeaturedUsers.swift b/Sources/VernissageServer/Migrations/CreateFeaturedUsers.swift new file mode 100644 index 0000000..34a5eb1 --- /dev/null +++ b/Sources/VernissageServer/Migrations/CreateFeaturedUsers.swift @@ -0,0 +1,43 @@ +// +// https://mczachurski.dev +// Copyright © 2024 Marcin Czachurski and the repository contributors. +// Licensed under the Apache License 2.0. +// + +import Vapor +import Fluent +import SQLKit + +extension FeaturedUser { + struct CreateFeaturedUsers: AsyncMigration { + func prepare(on database: Database) async throws { + try await database + .schema(FeaturedUser.schema) + .field(.id, .int64, .identifier(auto: false)) + .field("featuredUserId", .int64, .required, .references(User.schema, "id")) + .field("userId", .int64, .required, .references(User.schema, "id")) + .field("createdAt", .datetime) + .field("updatedAt", .datetime) + .unique(on: "featuredUserId", "userId") + .create() + + if let sqlDatabase = database as? SQLDatabase { + try await sqlDatabase + .create(index: "\(FeaturedUser.schema)_featuredUserIdIndex") + .on(FeaturedStatus.schema) + .column("statusId") + .run() + + try await sqlDatabase + .create(index: "\(FeaturedUser.schema)_userIdIndex") + .on(FeaturedStatus.schema) + .column("userId") + .run() + } + } + + func revert(on database: Database) async throws { + try await database.schema(FeaturedUser.schema).delete() + } + } +} diff --git a/Sources/VernissageServer/Models/ApplicationSettings.swift b/Sources/VernissageServer/Models/ApplicationSettings.swift index 63f8549..08b9a07 100644 --- a/Sources/VernissageServer/Models/ApplicationSettings.swift +++ b/Sources/VernissageServer/Models/ApplicationSettings.swift @@ -59,6 +59,7 @@ struct ApplicationSettings { let showLocalTimelineForAnonymous: Bool let showTrendingForAnonymous: Bool let showEditorsChoiceForAnonymous: Bool + let showEditorsUsersChoiceForAnonymous: Bool let showHashtagsForAnonymous: Bool let showCategoriesForAnonymous: Bool @@ -104,6 +105,7 @@ struct ApplicationSettings { showLocalTimelineForAnonymous: Bool = false, showTrendingForAnonymous: Bool = false, showEditorsChoiceForAnonymous: Bool = false, + showEditorsUsersChoiceForAnonymous: Bool = false, showHashtagsForAnonymous: Bool = false, showCategoriesForAnonymous: Bool = false ) { @@ -183,6 +185,7 @@ struct ApplicationSettings { self.showLocalTimelineForAnonymous = showLocalTimelineForAnonymous self.showTrendingForAnonymous = showTrendingForAnonymous self.showEditorsChoiceForAnonymous = showEditorsChoiceForAnonymous + self.showEditorsUsersChoiceForAnonymous = showEditorsUsersChoiceForAnonymous self.showHashtagsForAnonymous = showHashtagsForAnonymous self.showCategoriesForAnonymous = showCategoriesForAnonymous } diff --git a/Sources/VernissageServer/Models/Event.swift b/Sources/VernissageServer/Models/Event.swift index cce2add..f8f1db4 100644 --- a/Sources/VernissageServer/Models/Event.swift +++ b/Sources/VernissageServer/Models/Event.swift @@ -56,6 +56,8 @@ enum EventType: String, Codable, CaseIterable { case userRolesDisconnect case userApprove case userReject + case userFeature + case userUnfeature case usersStatuses case avatarUpdate @@ -118,7 +120,8 @@ enum EventType: String, Codable, CaseIterable { case timelinesPublic case timelinesCategories case timelinesHashtags - case timelinesFeatured + case timelinesFeaturedStatuses + case timelinesFeaturedUsers case timelinesHome case followRequestList diff --git a/Sources/VernissageServer/Models/FeaturedUser.swift b/Sources/VernissageServer/Models/FeaturedUser.swift new file mode 100644 index 0000000..7ddbfe8 --- /dev/null +++ b/Sources/VernissageServer/Models/FeaturedUser.swift @@ -0,0 +1,42 @@ +// +// https://mczachurski.dev +// Copyright © 2024 Marcin Czachurski and the repository contributors. +// Licensed under the Apache License 2.0. +// + +import Fluent +import Vapor +import ActivityPubKit + +/// Featured status. +final class FeaturedUser: Model, @unchecked Sendable { + static let schema: String = "FeaturedUsers" + + @ID(custom: .id, generatedBy: .user) + var id: Int64? + + @Parent(key: "featuredUserId") + var featuredUser: User + + @Parent(key: "userId") + var user: User + + @Timestamp(key: "createdAt", on: .create) + var createdAt: Date? + + @Timestamp(key: "updatedAt", on: .update) + var updatedAt: Date? + + init() { } + + convenience init(id: Int64, featuredUserId: Int64, userId: Int64) { + self.init() + + self.id = id + self.$featuredUser.id = featuredUserId + self.$user.id = userId + } +} + +/// Allows `FeaturedUser` to be encoded to and decoded from HTTP messages. +extension FeaturedUser: Content { } diff --git a/Sources/VernissageServer/Models/Setting.swift b/Sources/VernissageServer/Models/Setting.swift index c962302..c01cf7f 100644 --- a/Sources/VernissageServer/Models/Setting.swift +++ b/Sources/VernissageServer/Models/Setting.swift @@ -105,6 +105,7 @@ public enum SettingKey: String { case showLocalTimelineForAnonymous case showTrendingForAnonymous case showEditorsChoiceForAnonymous + case showEditorsUsersChoiceForAnonymous case showHashtagsForAnonymous case showCategoriesForAnonymous } diff --git a/Sources/VernissageServer/Services/FollowsService.swift b/Sources/VernissageServer/Services/FollowsService.swift index d76a22b..d14b1fc 100644 --- a/Sources/VernissageServer/Services/FollowsService.swift +++ b/Sources/VernissageServer/Services/FollowsService.swift @@ -103,8 +103,12 @@ final class FollowsService: FollowsServiceType { public func following(on request: Request, sourceId: Int64, onlyApproved: Bool, linkableParams: LinkableParams) async throws -> LinkableResult { var queryBuilder = Follow.query(on: request.db) - .with(\.$target) - + .with(\.$target) { target in + target + .with(\.$flexiFields) + .with(\.$roles) + } + if onlyApproved { queryBuilder .group(.and) { queryGroup in @@ -177,7 +181,11 @@ final class FollowsService: FollowsServiceType { public func follows(on request: Request, targetId: Int64, onlyApproved: Bool, linkableParams: LinkableParams) async throws -> LinkableResult { var queryBuilder = Follow.query(on: request.db) - .with(\.$source) + .with(\.$source) { source in + source + .with(\.$flexiFields) + .with(\.$roles) + } if onlyApproved { queryBuilder diff --git a/Sources/VernissageServer/Services/NotificationsService.swift b/Sources/VernissageServer/Services/NotificationsService.swift index a167d6e..7d48e5a 100644 --- a/Sources/VernissageServer/Services/NotificationsService.swift +++ b/Sources/VernissageServer/Services/NotificationsService.swift @@ -132,7 +132,11 @@ final class NotificationsService: NotificationsServiceType { var query = Notification.query(on: database) .filter(\.$user.$id == userId) - .with(\.$byUser) + .with(\.$byUser) { byUser in + byUser + .with(\.$flexiFields) + .with(\.$roles) + } .with(\.$status) { status in status.with(\.$attachments) { attachment in attachment.with(\.$originalFile) diff --git a/Sources/VernissageServer/Services/SearchService.swift b/Sources/VernissageServer/Services/SearchService.swift index 7d82975..a90171f 100644 --- a/Sources/VernissageServer/Services/SearchService.swift +++ b/Sources/VernissageServer/Services/SearchService.swift @@ -70,13 +70,10 @@ final class SearchService: SearchServiceType { } let flexiFieldService = request.application.services.flexiFieldService - let storageService = request.application.services.storageService - - let baseStoragePath = storageService.getBaseStoragePath(on: request.application) - let baseAddress = request.application.settings.cached?.baseAddress ?? "" + let usersService = request.application.services.usersService let flexiFields = try? await flexiFieldService.getFlexiFields(on: request.db, for: user.requireID()) - let userDto = UserDto(from: user, flexiFields: flexiFields, baseStoragePath: baseStoragePath, baseAddress: baseAddress) + let userDto = await usersService.convertToDto(on: request, user: user, flexiFields: flexiFields, roles: nil, attachSensitive: false) // Enqueue job for flexi field URL validator. if let flexiFields { @@ -269,6 +266,8 @@ final class SearchService: SearchServiceType { .filter(\.$activityPubProfile == query) .filter(\.$url == query) } + .with(\.$flexiFields) + .with(\.$roles) .sort(\.$followersCount, .descending) .paginate(PageRequest(page: 1, per: 20)) @@ -278,15 +277,9 @@ final class SearchService: SearchServiceType { return SearchResultDto(users: []) } - let baseStoragePath = request.application.services.storageService.getBaseStoragePath(on: request.application) - let baseAddress = request.application.settings.cached?.baseAddress ?? "" - - // Map databse user into DTO objects. - let userDtos = await users.items.asyncMap { user in - let flexiFields = try? await user.$flexiFields.get(on: request.db) - return UserDto(from: user, flexiFields: flexiFields, baseStoragePath: baseStoragePath, baseAddress: baseAddress) - } - + let usersService = request.application.services.usersService + let userDtos = await usersService.convertToDtos(on: request, users: users.items, attachSensitive: false) + return SearchResultDto(users: userDtos) } diff --git a/Sources/VernissageServer/Services/SettingsService.swift b/Sources/VernissageServer/Services/SettingsService.swift index 7bb0c67..b404ecc 100644 --- a/Sources/VernissageServer/Services/SettingsService.swift +++ b/Sources/VernissageServer/Services/SettingsService.swift @@ -98,6 +98,7 @@ final class SettingsService: SettingsServiceType { showLocalTimelineForAnonymous: settingsFromDb.getBool(.showLocalTimelineForAnonymous) ?? false, showTrendingForAnonymous: settingsFromDb.getBool(.showTrendingForAnonymous) ?? false, showEditorsChoiceForAnonymous: settingsFromDb.getBool(.showEditorsChoiceForAnonymous) ?? false, + showEditorsUsersChoiceForAnonymous: settingsFromDb.getBool(.showEditorsUsersChoiceForAnonymous) ?? false, showHashtagsForAnonymous: settingsFromDb.getBool(.showHashtagsForAnonymous) ?? false, showCategoriesForAnonymous: settingsFromDb.getBool(.showCategoriesForAnonymous) ?? false ) diff --git a/Sources/VernissageServer/Services/StatusesService.swift b/Sources/VernissageServer/Services/StatusesService.swift index 0a37740..c4f5c83 100644 --- a/Sources/VernissageServer/Services/StatusesService.swift +++ b/Sources/VernissageServer/Services/StatusesService.swift @@ -454,7 +454,11 @@ final class StatusesService: StatusesServiceType { public func reblogged(on request: Request, statusId: Int64, linkableParams: LinkableParams) async throws -> LinkableResult { var queryBuilder = Status.query(on: request.db) - .with(\.$user) + .with(\.$user) { user in + user + .with(\.$flexiFields) + .with(\.$roles) + } .filter(\.$reblog.$id == statusId) if let minId = linkableParams.minId?.toId() { @@ -491,7 +495,11 @@ final class StatusesService: StatusesServiceType { public func favourited(on request: Request, statusId: Int64, linkableParams: LinkableParams) async throws -> LinkableResult { var queryBuilder = StatusFavourite.query(on: request.db) - .with(\.$user) + .with(\.$user) { user in + user + .with(\.$flexiFields) + .with(\.$roles) + } .filter(\.$status.$id == statusId) if let minId = linkableParams.minId?.toId() { diff --git a/Sources/VernissageServer/Services/TimelineService.swift b/Sources/VernissageServer/Services/TimelineService.swift index 0f70b90..0c53f0f 100644 --- a/Sources/VernissageServer/Services/TimelineService.swift +++ b/Sources/VernissageServer/Services/TimelineService.swift @@ -30,7 +30,8 @@ protocol TimelineServiceType: Sendable { func `public`(on database: Database, linkableParams: LinkableParams, onlyLocal: Bool) async throws -> [Status] func category(on database: Database, linkableParams: LinkableParams, categoryId: Int64, onlyLocal: Bool) async throws -> [Status] func hashtags(on database: Database, linkableParams: LinkableParams, hashtag: String, onlyLocal: Bool) async throws -> [Status] - func featured(on database: Database, linkableParams: LinkableParams, onlyLocal: Bool) async throws -> LinkableResult + func featuredStatuses(on database: Database, linkableParams: LinkableParams, onlyLocal: Bool) async throws -> LinkableResult + func featuredUsers(on database: Database, linkableParams: LinkableParams, onlyLocal: Bool) async throws -> LinkableResult } /// A service for managing main timelines. @@ -341,7 +342,7 @@ final class TimelineService: TimelineServiceType { return statuses.sorted(by: { $0.id ?? 0 > $1.id ?? 0 }) } - func featured(on database: Database, linkableParams: LinkableParams, onlyLocal: Bool = false) async throws -> LinkableResult { + func featuredStatuses(on database: Database, linkableParams: LinkableParams, onlyLocal: Bool = false) async throws -> LinkableResult { var query = FeaturedStatus.query(on: database) .filter(\.$createdAt > Date.yearAgo) .with(\.$status) { status in @@ -390,4 +391,46 @@ final class TimelineService: TimelineServiceType { data: sortedFeaturedStatuses.map({ $0.status }) ) } + + func featuredUsers(on database: Database, linkableParams: LinkableParams, onlyLocal: Bool = false) async throws -> LinkableResult { + var query = FeaturedUser.query(on: database) + .filter(\.$createdAt > Date.yearAgo) + .with(\.$featuredUser) { featuredUser in + featuredUser + .with(\.$hashtags) + .with(\.$flexiFields) + .with(\.$roles) + } + + if let minId = linkableParams.minId?.toId() { + query = query + .filter(\.$id > minId) + .sort(\.$createdAt, .ascending) + } + else if let maxId = linkableParams.maxId?.toId() { + query = query + .filter(\.$id < maxId) + .sort(\.$createdAt, .descending) + } + else if let sinceId = linkableParams.sinceId?.toId() { + query = query + .filter(\.$id > sinceId) + .sort(\.$createdAt, .descending) + } else { + query = query + .sort(\.$createdAt, .descending) + } + + let featuredUsers = try await query + .limit(linkableParams.limit) + .all() + + let sortedFeaturedUsers = featuredUsers.sorted(by: { $0.id ?? 0 > $1.id ?? 0 }) + + return LinkableResult( + maxId: sortedFeaturedUsers.last?.stringId(), + minId: sortedFeaturedUsers.first?.stringId(), + data: sortedFeaturedUsers.map({ $0.featuredUser }) + ) + } } diff --git a/Sources/VernissageServer/Services/TrendingService.swift b/Sources/VernissageServer/Services/TrendingService.swift index 30dffba..2b1605a 100644 --- a/Sources/VernissageServer/Services/TrendingService.swift +++ b/Sources/VernissageServer/Services/TrendingService.swift @@ -232,7 +232,9 @@ final class TrendingService: TrendingServiceType { var query = TrendingUser.query(on: database) .filter(\.$trendingPeriod == period) .with(\.$user) { user in - user.with(\.$flexiFields) + user + .with(\.$flexiFields) + .with(\.$roles) } if let minId = linkableParams.minId?.toId() { diff --git a/Sources/VernissageServer/Services/UsersService.swift b/Sources/VernissageServer/Services/UsersService.swift index 919f20c..e4ffb73 100644 --- a/Sources/VernissageServer/Services/UsersService.swift +++ b/Sources/VernissageServer/Services/UsersService.swift @@ -35,6 +35,8 @@ protocol UsersServiceType: Sendable { func get(on database: Database, activityPubProfile: String) async throws -> User? func getModerators(on database: Database) async throws -> [User] func getDefaultSystemUser(on database: Database) async throws -> User? + func convertToDto(on request: Request, user: User, flexiFields: [FlexiField]?, roles: [Role]?, attachSensitive: Bool) async -> UserDto + func convertToDtos(on request: Request, users: [User], attachSensitive: Bool) async -> [UserDto] func login(on request: Request, userNameOrEmail: String, password: String, isMachineTrusted: Bool) async throws -> User func login(on request: Request, authenticateToken: String) async throws -> User func forgotPassword(on request: Request, email: String) async throws -> User @@ -75,22 +77,38 @@ final class UsersService: UsersServiceType { } func get(on database: Database, id: Int64) async throws -> User? { - return try await User.query(on: database).filter(\.$id == id).first() + return try await User.query(on: database) + .filter(\.$id == id) + .with(\.$flexiFields) + .with(\.$roles) + .first() } func get(on database: Database, userName: String) async throws -> User? { let userNameNormalized = userName.uppercased() - return try await User.query(on: database).filter(\.$userNameNormalized == userNameNormalized).first() + return try await User.query(on: database) + .filter(\.$userNameNormalized == userNameNormalized) + .with(\.$flexiFields) + .with(\.$roles) + .first() } func get(on database: Database, account: String) async throws -> User? { let accountNormalized = account.uppercased() - return try await User.query(on: database).filter(\.$accountNormalized == accountNormalized).first() + return try await User.query(on: database) + .filter(\.$accountNormalized == accountNormalized) + .with(\.$flexiFields) + .with(\.$roles) + .first() } func get(on database: Database, activityPubProfile: String) async throws -> User? { let activityPubProfileNormalized = activityPubProfile.uppercased() - return try await User.query(on: database).filter(\.$activityPubProfileNormalized == activityPubProfileNormalized).first() + return try await User.query(on: database) + .filter(\.$activityPubProfileNormalized == activityPubProfileNormalized) + .with(\.$flexiFields) + .with(\.$roles) + .first() } func getModerators(on database: Database) async throws -> [User] { @@ -107,6 +125,35 @@ final class UsersService: UsersServiceType { return moderators.uniqued { user in user.id } } + func convertToDto(on request: Request, user: User, flexiFields: [FlexiField]?, roles: [Role]?, attachSensitive: Bool) async -> UserDto { + let isFeatured = try? await self.userIsFeatured(on: request, userId: user.requireID()) + + let userProfile = self.getUserProfile(on: request, + user: user, + flexiFields: flexiFields, + roles: roles, + attachSensitive: attachSensitive, + isFeatured: isFeatured ?? false) + return userProfile + } + + func convertToDtos(on request: Request, users: [User], attachSensitive: Bool) async -> [UserDto] { + let userIds = users.compactMap { $0.id } + let featuredUsers = try? await self.usersAreFeatured(on: request, userIds: userIds) + + let userDtos = await users.asyncMap { user in + let userProfile = self.getUserProfile(on: request, + user: user, + flexiFields: user.flexiFields, + roles: user.roles, + attachSensitive: attachSensitive, + isFeatured: featuredUsers?.contains(where: { $0 == user.id }) ?? false) + return userProfile + } + + return userDtos + } + func login(on request: Request, userNameOrEmail: String, password: String, isMachineTrusted: Bool) async throws -> User { let userNameOrEmailNormalized = userNameOrEmail.uppercased() @@ -887,4 +934,55 @@ final class UsersService: UsersServiceType { return try await User.query(on: database).filter(\.$id == systemUserId).first() } + + private func getUserProfile(on request: Request, user: User, flexiFields: [FlexiField]?, roles: [Role]?, attachSensitive: Bool, isFeatured: Bool) -> UserDto { + let baseStoragePath = request.application.services.storageService.getBaseStoragePath(on: request.application) + let baseAddress = request.application.settings.cached?.baseAddress ?? "" + + var userDto = UserDto(from: user, + flexiFields: flexiFields, + roles: attachSensitive ? roles : nil, + baseStoragePath: baseStoragePath, + baseAddress: baseAddress, + featured: isFeatured) + + if attachSensitive { + userDto.email = user.email + userDto.emailWasConfirmed = user.emailWasConfirmed + userDto.locale = user.locale + userDto.isBlocked = user.isBlocked + userDto.isApproved = user.isApproved + userDto.twoFactorEnabled = user.twoFactorEnabled + userDto.manuallyApprovesFollowers = user.manuallyApprovesFollowers + } + + return userDto + } + + private func userIsFeatured(on request: Request, userId: Int64) async throws -> Bool { + guard let authorizationPayloadId = request.userId else { + return false + } + + let amount = try await FeaturedUser.query(on: request.db) + .filter(\.$user.$id == authorizationPayloadId) + .filter(\.$featuredUser.$id == userId) + .count() + + return amount > 0 + } + + private func usersAreFeatured(on request: Request, userIds: [Int64]) async throws -> [Int64] { + guard let authorizationPayloadId = request.userId else { + return [] + } + + let featuredUsers = try await FeaturedUser.query(on: request.db) + .filter(\.$user.$id == authorizationPayloadId) + .filter(\.$featuredUser.$id ~~ userIds) + .field(\.$featuredUser.$id) + .all() + + return featuredUsers.map({ $0.$featuredUser.id }) + } } diff --git a/Tests/VernissageServerTests/AcceptanceTests/StatusesController/StatusesFeatureActionTests.swift b/Tests/VernissageServerTests/AcceptanceTests/StatusesController/StatusesFeatureActionTests.swift index 068a5ac..334ceb1 100644 --- a/Tests/VernissageServerTests/AcceptanceTests/StatusesController/StatusesFeatureActionTests.swift +++ b/Tests/VernissageServerTests/AcceptanceTests/StatusesController/StatusesFeatureActionTests.swift @@ -12,7 +12,7 @@ import Fluent extension ControllersTests { - @Suite("Statuses (GET /statuses/:id/feature)", .serialized, .tags(.statuses)) + @Suite("Statuses (POST /statuses/:id/feature)", .serialized, .tags(.statuses)) struct StatusesFeatureActionTests { var application: Application! diff --git a/Tests/VernissageServerTests/AcceptanceTests/TimelinesController/TimelinesFeaturedActionTests.swift b/Tests/VernissageServerTests/AcceptanceTests/TimelinesController/TimelinesFeaturedStatusesActionTests.swift similarity index 92% rename from Tests/VernissageServerTests/AcceptanceTests/TimelinesController/TimelinesFeaturedActionTests.swift rename to Tests/VernissageServerTests/AcceptanceTests/TimelinesController/TimelinesFeaturedStatusesActionTests.swift index c1c69d5..28402a2 100644 --- a/Tests/VernissageServerTests/AcceptanceTests/TimelinesController/TimelinesFeaturedActionTests.swift +++ b/Tests/VernissageServerTests/AcceptanceTests/TimelinesController/TimelinesFeaturedStatusesActionTests.swift @@ -12,8 +12,8 @@ import Fluent extension ControllersTests { - @Suite("Timelines (GET /timelines/featured)", .serialized, .tags(.timelines)) - struct TimelinesFeaturedActionTests { + @Suite("Timelines (GET /timelines/featured-statuses)", .serialized, .tags(.timelines)) + struct TimelinesFeaturedStatusesActionTests { var application: Application! init() async throws { @@ -36,7 +36,7 @@ extension ControllersTests { // Act. let statusesFromApi = try application.getResponse( as: .user(userName: "timastonix", password: "p@ssword"), - to: "/timelines/featured?limit=2", + to: "/timelines/featured-statuses?limit=2", method: .GET, decodeTo: LinkableResultDto.self ) @@ -62,7 +62,7 @@ extension ControllersTests { // Act. let statusesFromApi = try application.getResponse( as: .user(userName: "trondastonix", password: "p@ssword"), - to: "/timelines/featured?limit=2&minId=\(featuredStatuses[5].id!)", + to: "/timelines/featured-statuses?limit=2&minId=\(featuredStatuses[5].id!)", method: .GET, decodeTo: LinkableResultDto.self ) @@ -89,7 +89,7 @@ extension ControllersTests { // Act. let statusesFromApi = try application.getResponse( as: .user(userName: "rickastonix", password: "p@ssword"), - to: "/timelines/featured?limit=2&maxId=\(featuredStatuses[5].id!)", + to: "/timelines/featured-statuses?limit=2&maxId=\(featuredStatuses[5].id!)", method: .GET, decodeTo: LinkableResultDto.self ) @@ -116,7 +116,7 @@ extension ControllersTests { // Act. let statusesFromApi = try application.getResponse( as: .user(userName: "benastonix", password: "p@ssword"), - to: "/timelines/featured?limit=20&sinceId=\(featuredStatuses[5].id!)", + to: "/timelines/featured-statuses?limit=20&sinceId=\(featuredStatuses[5].id!)", method: .GET, decodeTo: LinkableResultDto.self ) @@ -136,7 +136,7 @@ extension ControllersTests { // Act. let response = try application.sendRequest( - to: "/timelines/featured?limit=2", + to: "/timelines/featured-statuses?limit=2", method: .GET ) diff --git a/Tests/VernissageServerTests/AcceptanceTests/TimelinesController/TimelinesFeaturedUsersActionTests.swift b/Tests/VernissageServerTests/AcceptanceTests/TimelinesController/TimelinesFeaturedUsersActionTests.swift new file mode 100644 index 0000000..5c45342 --- /dev/null +++ b/Tests/VernissageServerTests/AcceptanceTests/TimelinesController/TimelinesFeaturedUsersActionTests.swift @@ -0,0 +1,142 @@ +// +// https://mczachurski.dev +// Copyright © 2024 Marcin Czachurski and the repository contributors. +// Licensed under the Apache License 2.0. +// + +@testable import VernissageServer +import ActivityPubKit +import Vapor +import Testing +import Fluent + +extension ControllersTests { + + @Suite("Timelines (GET /timelines/featured-users)", .serialized, .tags(.timelines)) + struct TimelinesFeaturedUsersActionTests { + var application: Application! + + init() async throws { + self.application = try await ApplicationManager.shared.application() + } + + @Test("Users should be returned without params") + func usersShouldBeReturnedWithoutParams() async throws { + + // Arrange. + try await application.updateSetting(key: .showEditorsUsersChoiceForAnonymous, value: .boolean(true)) + + let user1 = try await application.createUser(userName: "featureXUser1") + let user2 = try await application.createUser(userName: "featureXUser2") + let user3 = try await application.createUser(userName: "featureXUser3") + let user4 = try await application.createUser(userName: "featureXUser4") + _ = try await application.createFeaturedUser(user: user1, users: [user1, user2, user3, user4]) + + // Act. + let usersFromApi = try application.getResponse( + as: .user(userName: "featureXUser1", password: "p@ssword"), + to: "/timelines/featured-users?limit=2", + method: .GET, + decodeTo: LinkableResultDto.self + ) + // Assert. + #expect(usersFromApi.data.count == 2, "Users list should be returned.") + #expect(usersFromApi.data[0].userName == "featureXUser4", "First user is not visible.") + #expect(usersFromApi.data[1].userName == "featureXUser3", "Second user is not visible.") + } + + @Test("Users should be returned with minId") + func usersShouldBeReturnedWithMinId() async throws { + + // Arrange. + try await application.updateSetting(key: .showEditorsUsersChoiceForAnonymous, value: .boolean(true)) + + let user1 = try await application.createUser(userName: "featureYUser1") + let user2 = try await application.createUser(userName: "featureYUser2") + let user3 = try await application.createUser(userName: "featureYUser3") + let user4 = try await application.createUser(userName: "featureYUser4") + let featuredUsers = try await application.createFeaturedUser(user: user1, users: [user1, user2, user3, user4]) + + // Act. + let usersFromApi = try application.getResponse( + as: .user(userName: "featureYUser1", password: "p@ssword"), + to: "/timelines/featured-users?limit=2&minId=\(featuredUsers[1].id!)", + method: .GET, + decodeTo: LinkableResultDto.self + ) + + // Assert. + #expect(usersFromApi.data.count == 2, "Users list should be returned.") + #expect(usersFromApi.data[0].userName == "featureYUser4", "First user is not visible.") + #expect(usersFromApi.data[1].userName == "featureYUser3", "Second user is not visible.") + } + + @Test("Users should be returned with maxId") + func usersShouldBeReturnedWithMaxId() async throws { + + // Arrange. + try await application.updateSetting(key: .showEditorsUsersChoiceForAnonymous, value: .boolean(true)) + + let user1 = try await application.createUser(userName: "featureZUser1") + let user2 = try await application.createUser(userName: "featureZUser2") + let user3 = try await application.createUser(userName: "featureZUser3") + let user4 = try await application.createUser(userName: "featureZUser4") + let featuredUsers = try await application.createFeaturedUser(user: user1, users: [user1, user2, user3, user4]) + + // Act. + let usersFromApi = try application.getResponse( + as: .user(userName: "featureZUser1", password: "p@ssword"), + to: "/timelines/featured-users?limit=2&maxId=\(featuredUsers[2].id!)", + method: .GET, + decodeTo: LinkableResultDto.self + ) + + // Assert. + #expect(usersFromApi.data.count == 2, "Users list should be returned.") + #expect(usersFromApi.data[0].userName == "featureZUser2", "First status is not visible.") + #expect(usersFromApi.data[1].userName == "featureZUser1", "Second status is not visible.") + } + + @Test("Statuses should be returned with sinceId") + func statusesShouldBeReturnedWithSinceId() async throws { + + // Arrange. + try await application.updateSetting(key: .showEditorsUsersChoiceForAnonymous, value: .boolean(true)) + + let user1 = try await application.createUser(userName: "featureWUser1") + let user2 = try await application.createUser(userName: "featureWUser2") + let user3 = try await application.createUser(userName: "featureWUser3") + let user4 = try await application.createUser(userName: "featureWUser4") + let featuredUsers = try await application.createFeaturedUser(user: user1, users: [user1, user2, user3, user4]) + + // Act. + let statusesFromApi = try application.getResponse( + as: .user(userName: "featureWUser1", password: "p@ssword"), + to: "/timelines/featured-users?limit=20&sinceId=\(featuredUsers[0].id!)", + method: .GET, + decodeTo: LinkableResultDto.self + ) + + // Assert. + #expect(statusesFromApi.data.count == 3, "Statuses list should be returned.") + #expect(statusesFromApi.data[0].userName == "featureWUser4", "First status is not visible.") + #expect(statusesFromApi.data[1].userName == "featureWUser3", "Second status is not visible.") + #expect(statusesFromApi.data[2].userName == "featureWUser2", "Third status is not visible.") + } + + @Test("Users should not be returned when public access is disabled") + func usersShouldNotBeReturnedWhenPublicAccessIsDisabled() async throws { + // Arrange. + try await application.updateSetting(key: .showEditorsUsersChoiceForAnonymous, value: .boolean(false)) + + // Act. + let response = try application.sendRequest( + to: "/timelines/featured-users?limit=2", + method: .GET + ) + + // Assert. + #expect(response.status == HTTPResponseStatus.unauthorized, "Response http status code should be unauthorized (401).") + } + } +} diff --git a/Tests/VernissageServerTests/AcceptanceTests/UsersController/UsersFeatureActionTests.swift b/Tests/VernissageServerTests/AcceptanceTests/UsersController/UsersFeatureActionTests.swift new file mode 100644 index 0000000..1d43fc1 --- /dev/null +++ b/Tests/VernissageServerTests/AcceptanceTests/UsersController/UsersFeatureActionTests.swift @@ -0,0 +1,96 @@ +// +// https://mczachurski.dev +// Copyright © 2024 Marcin Czachurski and the repository contributors. +// Licensed under the Apache License 2.0. +// + +@testable import VernissageServer +import ActivityPubKit +import Vapor +import Testing +import Fluent + +extension ControllersTests { + + @Suite("Users (GET /users/:username/feature)", .serialized, .tags(.statuses)) + struct UsersFeatureActionTests { + var application: Application! + + init() async throws { + self.application = try await ApplicationManager.shared.application() + } + + @Test("User should be featured for moderator") + func userShouldBeFeaturedForModerator() async throws { + + // Arrange. + let user1 = try await application.createUser(userName: "roxyborin") + let user2 = try await application.createUser(userName: "tobyborin") + try await application.attach(user: user2, role: Role.moderator) + + // Act. + let userDto = try application.getResponse( + as: .user(userName: "tobyborin", password: "p@ssword"), + to: "/users/@\(user1.userName)/feature", + method: .POST, + decodeTo: UserDto.self + ) + + // Assert. + #expect(userDto.id != nil, "User wasn't featured.") + #expect(userDto.featured == true, "User should be marked as featured.") + } + + @Test("Forbidden should be returned for regular user") + func forbiddenShouldbeReturnedForRegularUser() async throws { + + // Arrange. + let user1 = try await application.createUser(userName: "carineborin") + _ = try await application.createUser(userName: "adameborin") + + // Act. + let response = try application.sendRequest( + as: .user(userName: "adameborin", password: "p@ssword"), + to: "/users/@\(user1.userName)/feature", + method: .POST + ) + + // Assert. + #expect(response.status == HTTPResponseStatus.forbidden, "Response http status code should be forbidden (403).") + } + + @Test("Not found should be returned if user not exists") + func notFoundShouldBeReturnedIfUserNotExists() async throws { + + // Arrange. + let user1 = try await application.createUser(userName: "maxeborin") + try await application.attach(user: user1, role: Role.moderator) + + // Act. + let errorResponse = try application.getErrorResponse( + as: .user(userName: "maxeborin", password: "p@ssword"), + to: "/users/@notfounded/feature", + method: .POST + ) + + // Assert. + #expect(errorResponse.status == HTTPResponseStatus.notFound, "Response http status code should be not found (404).") + } + + @Test("Unauthorized should be returned for not authorized user") + func unauthorizedShouldBeReturnedForNotAuthorizedUser() async throws { + + // Arrange. + let user1 = try await application.createUser(userName: "moiqueeborin") + + // Act. + let errorResponse = try application.getErrorResponse( + to: "/users/@\(user1.userName)/feature", + method: .POST + ) + + // Assert. + #expect(errorResponse.status == HTTPResponseStatus.unauthorized, "Response http status code should be unauthorized (401).") + } + } +} diff --git a/Tests/VernissageServerTests/AcceptanceTests/UsersController/UsersUnfeatureActionTests.swift b/Tests/VernissageServerTests/AcceptanceTests/UsersController/UsersUnfeatureActionTests.swift new file mode 100644 index 0000000..a28d16e --- /dev/null +++ b/Tests/VernissageServerTests/AcceptanceTests/UsersController/UsersUnfeatureActionTests.swift @@ -0,0 +1,99 @@ +// +// https://mczachurski.dev +// Copyright © 2024 Marcin Czachurski and the repository contributors. +// Licensed under the Apache License 2.0. +// + +@testable import VernissageServer +import ActivityPubKit +import Vapor +import Testing +import Fluent + +extension ControllersTests { + + @Suite("Users (POST /users/:username/unfeature)", .serialized, .tags(.statuses)) + struct UsersUnfeatureActionTests { + var application: Application! + + init() async throws { + self.application = try await ApplicationManager.shared.application() + } + + @Test("User should be unfeatured for moderator") + func userShouldBeUnfeaturedForModerator() async throws { + + // Arrange. + let user1 = try await application.createUser(userName: "maximgupok") + let user2 = try await application.createUser(userName: "roxygupok") + + try await application.attach(user: user1, role: Role.moderator) + _ = try await application.createFeaturedUser(user: user1, featuredUser: user2) + + // Act. + let userDto = try application.getResponse( + as: .user(userName: "maximgupok", password: "p@ssword"), + to: "/users/@\(user2.userName)/unfeature", + method: .POST, + decodeTo: UserDto.self + ) + + // Assert. + #expect(userDto.id != nil, "User wasn't unfeatured.") + #expect(userDto.featured == false, "User should be marked as unfeatured.") + } + + @Test("Forbidden should be returned for regular user") + func forbiddenShouldbeReturnedForRegularUser() async throws { + + // Arrange. + let user1 = try await application.createUser(userName: "caringupok") + let user2 = try await application.createUser(userName: "adamgupok") + _ = try await application.createFeaturedUser(user: user1, featuredUser: user2) + + // Act. + let response = try application.sendRequest( + as: .user(userName: "caringupok", password: "p@ssword"), + to: "/users/@\(user2.userName)/unfeature", + method: .POST + ) + + // Assert. + #expect(response.status == HTTPResponseStatus.forbidden, "Response http status code should be forbidden (403).") + } + + @Test("Not found should be returned if user not exists") + func notFoundShouldBeReturnedIfUserNotExists() async throws { + + // Arrange. + let user1 = try await application.createUser(userName: "maxgupok") + try await application.attach(user: user1, role: Role.moderator) + + // Act. + let errorResponse = try application.getErrorResponse( + as: .user(userName: "maxgupok", password: "p@ssword"), + to: "/users/@notfounded/unfeature", + method: .POST + ) + + // Assert. + #expect(errorResponse.status == HTTPResponseStatus.notFound, "Response http status code should be not found (404).") + } + + @Test("Unauthorized should be returned for not authorized user") + func unauthorizedShouldBeReturnedForNotAuthorizedUser() async throws { + + // Arrange. + let user1 = try await application.createUser(userName: "moiquegupok") + + // Act. + let errorResponse = try application.getErrorResponse( + to: "/users/@\(user1.userName)/unfeature", + method: .POST + ) + + // Assert. + #expect(errorResponse.status == HTTPResponseStatus.unauthorized, "Response http status code should be unauthorized (401).") + } + } +} diff --git a/Tests/VernissageServerTests/Helpers/Extensions/FeatureUser.swift b/Tests/VernissageServerTests/Helpers/Extensions/FeatureUser.swift new file mode 100644 index 0000000..9b50a9d --- /dev/null +++ b/Tests/VernissageServerTests/Helpers/Extensions/FeatureUser.swift @@ -0,0 +1,38 @@ +// +// https://mczachurski.dev +// Copyright © 2024 Marcin Czachurski and the repository contributors. +// Licensed under the Apache License 2.0. +// + +@testable import VernissageServer +import Vapor +import Fluent + +extension Application { + func createFeaturedUser(user: User, featuredUser: User) async throws -> FeaturedUser { + let id = await ApplicationManager.shared.generateId() + let featuredUser = try FeaturedUser(id: id, featuredUserId: featuredUser.requireID(), userId: user.requireID()) + _ = try await featuredUser.save(on: self.db) + return featuredUser + } + + func createFeaturedUser(user: User, users: [User]) async throws -> [FeaturedUser] { + var list: [FeaturedUser] = [] + for item in users { + let id = await ApplicationManager.shared.generateId() + let featuredUser = try FeaturedUser(id: id, featuredUserId: item.requireID(), userId: user.requireID()) + try await featuredUser.save(on: self.db) + + list.append(featuredUser) + } + + return list + } + + func getAllFeaturedUsers() async throws -> [FeaturedUser] { + try await FeaturedUser.query(on: self.db) + .with(\.$featuredUser) + .sort(\.$createdAt, .descending) + .all() + } +}