From df26db05850c27e201f14b1beade2152e26feb13 Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Fri, 27 Sep 2024 22:42:47 +0200 Subject: [PATCH] [backend/federation] Add relay support (ISH-266) --- .../Controllers/Web/AdminController.cs | 37 ++++++++- .../DatabaseContextModelSnapshot.cs | 8 ++ ...0927214613_AddRelayAndSystemUserColumns.cs | 51 +++++++++++++ .../Core/Database/Tables/Relay.cs | 6 +- .../Core/Database/Tables/User.cs | 8 +- .../Core/Extensions/ServiceExtensions.cs | 3 +- .../ActivityPub/ActivityDeliverService.cs | 15 ++++ .../ActivityPub/ActivityHandlerService.cs | 20 ++++- .../ActivityPub/ActivityRenderer.cs | 10 +++ .../Federation/ActivityPub/UserResolver.cs | 18 ++++- .../ActivityStreams/Types/ASActivity.cs | 3 + .../Middleware/InboxValidationMiddleware.cs | 6 +- .../Core/Queues/PreDeliverQueue.cs | 44 ++++++++--- .../Core/Services/RelayService.cs | 75 +++++++++++++++++++ .../Core/Services/SystemUserService.cs | 3 +- Iceshrimp.Backend/configuration.ini | 4 +- Iceshrimp.Shared/Schemas/Web/RelaySchemas.cs | 23 ++++++ 17 files changed, 307 insertions(+), 27 deletions(-) create mode 100644 Iceshrimp.Backend/Core/Database/Migrations/v2024.1-beta4/20240927214613_AddRelayAndSystemUserColumns.cs create mode 100644 Iceshrimp.Backend/Core/Services/RelayService.cs create mode 100644 Iceshrimp.Shared/Schemas/Web/RelaySchemas.cs diff --git a/Iceshrimp.Backend/Controllers/Web/AdminController.cs b/Iceshrimp.Backend/Controllers/Web/AdminController.cs index de8adb6a..837b7c52 100644 --- a/Iceshrimp.Backend/Controllers/Web/AdminController.cs +++ b/Iceshrimp.Backend/Controllers/Web/AdminController.cs @@ -33,7 +33,8 @@ public class AdminController( ActivityPub.NoteRenderer noteRenderer, ActivityPub.UserRenderer userRenderer, IOptions config, - QueueService queueSvc + QueueService queueSvc, + RelayService relaySvc ) : ControllerBase { [HttpPost("invites/generate")] @@ -119,7 +120,7 @@ public class AdminController( await foreach (var job in jobs) await queueSvc.RetryJobAsync(job); } - + [HttpPost("queue/{queue}/retry-range/{from::guid}/{to::guid}")] [ProducesResults(HttpStatusCode.OK)] public async Task RetryRange(string queue, Guid from, Guid to) @@ -144,6 +145,38 @@ public class AdminController( await queueSvc.AbandonJobAsync(job); } + [HttpGet("relays")] + [ProducesResults(HttpStatusCode.OK)] + public async Task> GetRelays() + { + return await db.Relays + .ToArrayAsync() + .ContinueWithResult(res => res.Select(p => new RelaySchemas.RelayResponse + { + Id = p.Id, + Inbox = p.Inbox, + Status = (RelaySchemas.RelayStatus)p.Status + }) + .ToList()); + } + + [HttpPost("relays")] + [ProducesResults(HttpStatusCode.OK)] + public async Task SubscribeToRelay(RelaySchemas.RelayRequest rq) + { + await relaySvc.SubscribeToRelay(rq.Inbox); + } + + [HttpDelete("relays/{id}")] + [ProducesResults(HttpStatusCode.OK)] + [ProducesErrors(HttpStatusCode.NotFound)] + public async Task UnsubscribeFromRelay(string id) + { + var relay = await db.Relays.FirstOrDefaultAsync(p => p.Id == id) ?? + throw GracefulException.NotFound("Relay not found"); + await relaySvc.UnsubscribeFromRelay(relay); + } + [UseNewtonsoftJson] [HttpGet("activities/notes/{id}")] [OverrideResultType] diff --git a/Iceshrimp.Backend/Core/Database/Migrations/DatabaseContextModelSnapshot.cs b/Iceshrimp.Backend/Core/Database/Migrations/DatabaseContextModelSnapshot.cs index bc36b8b4..f1160370 100644 --- a/Iceshrimp.Backend/Core/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Iceshrimp.Backend/Core/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -4053,6 +4053,10 @@ namespace Iceshrimp.Backend.Core.Database.Migrations .HasColumnName("isModerator") .HasComment("Whether the User is a moderator."); + b.Property("IsRelayActor") + .HasColumnType("boolean") + .HasColumnName("isRelayActor"); + b.Property("IsSilenced") .ValueGeneratedOnAdd() .HasColumnType("boolean") @@ -4067,6 +4071,10 @@ namespace Iceshrimp.Backend.Core.Database.Migrations .HasColumnName("isSuspended") .HasComment("Whether the User is suspended."); + b.Property("IsSystem") + .HasColumnType("boolean") + .HasColumnName("isSystem"); + b.Property("LastActiveDate") .HasColumnType("timestamp with time zone") .HasColumnName("lastActiveDate"); diff --git a/Iceshrimp.Backend/Core/Database/Migrations/v2024.1-beta4/20240927214613_AddRelayAndSystemUserColumns.cs b/Iceshrimp.Backend/Core/Database/Migrations/v2024.1-beta4/20240927214613_AddRelayAndSystemUserColumns.cs new file mode 100644 index 00000000..6d694210 --- /dev/null +++ b/Iceshrimp.Backend/Core/Database/Migrations/v2024.1-beta4/20240927214613_AddRelayAndSystemUserColumns.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Infrastructure; + +#nullable disable + +namespace Iceshrimp.Backend.Core.Database.Migrations +{ + /// + [DbContext(typeof(DatabaseContext))] + [Migration("20240927214613_AddRelayAndSystemUserColumns")] + public partial class AddRelayAndSystemUserColumns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "isRelayActor", + table: "user", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "isSystem", + table: "user", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.Sql(""" + UPDATE "user" SET "isSystem" = TRUE WHERE "host" IS NULL AND ("usernameLower" = 'instance.actor' OR "usernameLower" = 'relay.actor'); + """); + + migrationBuilder.Sql(""" + UPDATE "user" SET "isRelayActor" = TRUE WHERE "inbox" IN (SELECT "inbox" FROM "relay"); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "isRelayActor", + table: "user"); + + migrationBuilder.DropColumn( + name: "isSystem", + table: "user"); + } + } +} diff --git a/Iceshrimp.Backend/Core/Database/Tables/Relay.cs b/Iceshrimp.Backend/Core/Database/Tables/Relay.cs index f5faa226..c139032c 100644 --- a/Iceshrimp.Backend/Core/Database/Tables/Relay.cs +++ b/Iceshrimp.Backend/Core/Database/Tables/Relay.cs @@ -12,9 +12,9 @@ public class Relay [PgName("relay_status_enum")] public enum RelayStatus { - [PgName("requesting")] Requesting, - [PgName("accepted")] Accepted, - [PgName("rejected")] Rejected + [PgName("requesting")] Requesting = 0, + [PgName("accepted")] Accepted = 1, + [PgName("rejected")] Rejected = 2 } [Key] diff --git a/Iceshrimp.Backend/Core/Database/Tables/User.cs b/Iceshrimp.Backend/Core/Database/Tables/User.cs index 96def218..fb832d4f 100644 --- a/Iceshrimp.Backend/Core/Database/Tables/User.cs +++ b/Iceshrimp.Backend/Core/Database/Tables/User.cs @@ -124,6 +124,10 @@ public class User : IEntity [Column("isBot")] public bool IsBot { get; set; } + [Column("isSystem")] public bool IsSystem { get; set; } + + [Column("isRelayActor")] public bool IsRelayActor { get; set; } + /// /// Whether the User is a cat. /// @@ -491,8 +495,8 @@ public class User : IEntity [NotMapped] public bool? PrecomputedIsRequested { get; set; } [NotMapped] public bool? PrecomputedIsRequestedBy { get; set; } - [Projectable] public bool IsLocalUser => Host == null; - [Projectable] public bool IsRemoteUser => Host != null; + [Projectable] public bool IsLocalUser => Host == null; + [Projectable] public bool IsRemoteUser => Host != null; [Projectable] public string IdenticonUrlPath => $"/identicon/{Id}"; [Key] diff --git a/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs b/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs index 8d0e0fa1..27304902 100644 --- a/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs +++ b/Iceshrimp.Backend/Core/Extensions/ServiceExtensions.cs @@ -82,7 +82,8 @@ public static class ServiceExtensions .AddScoped() .AddScoped() .AddScoped() - .AddScoped(); + .AddScoped() + .AddScoped(); // Singleton = instantiated once across application lifetime services diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityDeliverService.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityDeliverService.cs index 62412d68..46a3cfa3 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityDeliverService.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityDeliverService.cs @@ -64,4 +64,19 @@ public class ActivityDeliverService( await DeliverToAsync(activity, actor, recipients); } + + public async Task DeliverToAsync(ASActivity activity, User actor, string recipientInbox) + { + logger.LogDebug("Queuing deliver-to-inbox job for activity {id}", activity.Id); + if (activity.Actor == null) throw new Exception("Actor must not be null"); + + await queueService.DeliverQueue.EnqueueAsync(new DeliverJobData + { + RecipientHost = new Uri(recipientInbox).Host, + InboxUrl = recipientInbox, + Payload = activity.CompactToPayload(), + ContentType = "application/activity+json", + UserId = actor.Id + }); + } } \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs index 0e768c26..ded8b226 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityHandlerService.cs @@ -26,7 +26,8 @@ public class ActivityHandlerService( ObjectResolver objectResolver, FollowupTaskService followupTaskSvc, EmojiService emojiSvc, - EventService eventSvc + EventService eventSvc, + RelayService relaySvc ) { public async Task PerformActivityAsync(ASActivity activity, string? inboxUserId, string? authenticatedUserId) @@ -187,6 +188,13 @@ public class ActivityHandlerService( if (activity.Object is not ASFollow obj) throw GracefulException.UnprocessableEntity("Accept activity object is invalid"); + var relayPrefix = $"https://{config.Value.WebDomain}/activities/follow-relay/"; + if (obj.Id.StartsWith(relayPrefix)) + { + await relaySvc.HandleAccept(actor, obj.Id[relayPrefix.Length..]); + return; + } + var prefix = $"https://{config.Value.WebDomain}/follows/"; if (!obj.Id.StartsWith(prefix)) throw GracefulException.UnprocessableEntity($"Object id '{obj.Id}' not a valid follow request id"); @@ -227,6 +235,13 @@ public class ActivityHandlerService( if (activity.Object is not ASFollow follow) throw GracefulException.UnprocessableEntity("Reject activity object is invalid"); + var relayPrefix = $"https://{config.Value.WebDomain}/activities/follow-relay/"; + if (follow.Id.StartsWith(relayPrefix)) + { + await relaySvc.HandleReject(resolvedActor, follow.Id[relayPrefix.Length..]); + return; + } + if (follow is not { Actor: not null }) throw GracefulException.UnprocessableEntity("Refusing to reject object with invalid follow object"); @@ -436,6 +451,9 @@ public class ActivityHandlerService( return; } + if (resolvedActor.IsRelayActor) + return; + if (await db.Notes.AnyAsync(p => p.Uri == activity.Id)) { logger.LogDebug("Renote '{id}' already exists, skipping", activity.Id); diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs index 2745a6a3..cf8d6838 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/ActivityRenderer.cs @@ -112,6 +112,16 @@ public class ActivityRenderer( RenderFollowId(follower, followee, relationshipId)); } + public ASFollow RenderFollow(User actor, Relay relay) + { + return new ASFollow + { + Id = $"https://{config.Value.WebDomain}/activities/follow-relay/{relay.Id}", + Actor = userRenderer.RenderLite(actor), + Object = new ASObject { Id = "https://www.w3.org/ns/activitystreams#Public" } + }; + } + public ASActivity RenderUnfollow(User follower, User followee, Guid? relationshipId) { if (follower.IsLocalUser && followee.IsLocalUser) diff --git a/Iceshrimp.Backend/Core/Federation/ActivityPub/UserResolver.cs b/Iceshrimp.Backend/Core/Federation/ActivityPub/UserResolver.cs index 43366eb8..4b0b72ef 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityPub/UserResolver.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityPub/UserResolver.cs @@ -174,9 +174,21 @@ public class UserResolver( return (finalAcct, finalUri); } - private static string? GetAcctUri(WebFingerResponse fingerRes) => (fingerRes.Aliases ?? []) - .Prepend(fingerRes.Subject) - .FirstOrDefault(p => p.StartsWith("acct:")); + private static string? GetAcctUri(WebFingerResponse fingerRes) + { + var acct = (fingerRes.Aliases ?? []) + .Prepend(fingerRes.Subject) + .FirstOrDefault(p => p.StartsWith("acct:")); + if (acct != null) return acct; + + // AodeRelay doesn't prefix its actor's subject with acct, so we have to fall back to guessing here + acct = (fingerRes.Aliases ?? []) + .Prepend(fingerRes.Subject) + .FirstOrDefault(p => !p.Contains(':') && + !p.Contains(' ') && + p.Split("@").Length == 2); + return acct is not null ? $"acct:{acct}" : acct; + } public static string NormalizeQuery(string query) { diff --git a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASActivity.cs b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASActivity.cs index 31749dad..649852ec 100644 --- a/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASActivity.cs +++ b/Iceshrimp.Backend/Core/Federation/ActivityStreams/Types/ASActivity.cs @@ -91,6 +91,9 @@ public class ASAnnounce : ASActivity public class ASDelete : ASActivity { public ASDelete() => Type = Types.Delete; + + [J($"{Constants.ActivityStreamsNs}#to")] + public List? To { get; set; } } public class ASFollow : ASActivity diff --git a/Iceshrimp.Backend/Core/Middleware/InboxValidationMiddleware.cs b/Iceshrimp.Backend/Core/Middleware/InboxValidationMiddleware.cs index 73e87054..8988cff1 100644 --- a/Iceshrimp.Backend/Core/Middleware/InboxValidationMiddleware.cs +++ b/Iceshrimp.Backend/Core/Middleware/InboxValidationMiddleware.cs @@ -161,8 +161,10 @@ public class InboxValidationMiddleware( logger.LogDebug("Error validating HTTP signature: {error}", e.Message); } - if ((!verified || (key?.User.Uri != null && activity.Actor?.Id != key.User.Uri)) && - (activity is ASDelete || config.Value.AcceptLdSignatures)) + if ( + (!verified || (key?.User.Uri != null && activity.Actor?.Id != key.User.Uri)) && + (activity is ASDelete || config.Value.AcceptLdSignatures) + ) { if (activity is ASDelete) logger.LogDebug("Activity is ASDelete & actor uri is not matching, trying LD signature next..."); diff --git a/Iceshrimp.Backend/Core/Queues/PreDeliverQueue.cs b/Iceshrimp.Backend/Core/Queues/PreDeliverQueue.cs index 75553c82..e97e8f9a 100644 --- a/Iceshrimp.Backend/Core/Queues/PreDeliverQueue.cs +++ b/Iceshrimp.Backend/Core/Queues/PreDeliverQueue.cs @@ -80,19 +80,43 @@ public class PreDeliverQueue(int parallelism) } foreach (var inboxQueryResult in inboxQueryResults) + { + // @formatter:off await queueSvc.DeliverQueue.EnqueueAsync(new DeliverJobData { - RecipientHost = - inboxQueryResult.Host ?? - throw new Exception("Recipient host must not be null"), - InboxUrl = - inboxQueryResult.InboxUrl ?? - throw new - Exception("Recipient inboxUrl must not be null"), - Payload = payload, - ContentType = "application/activity+json", - UserId = jobData.ActorId + RecipientHost = inboxQueryResult.Host ?? throw new Exception("Recipient host must not be null"), + InboxUrl = inboxQueryResult.InboxUrl ?? throw new Exception("Recipient inboxUrl must not be null"), + Payload = payload, + ContentType = "application/activity+json", + UserId = jobData.ActorId }); + // @formatter:on + } + + if (activity is ASCreate or ASDelete { Object: ASNote }) + { + var relays = await db.Relays.ToArrayAsync(token); + if (relays is []) return; + + if (!config.Value.AttachLdSignatures || activity is ASDelete) + { + if (activity is ASDelete del) del.To = [new ASObjectBase($"{Constants.ActivityStreamsNs}#Public")]; + var keypair = await db.UserKeypairs.FirstAsync(p => p.UserId == jobData.ActorId, token); + payload = await activity.SignAndCompactAsync(keypair); + } + + foreach (var relay in relays) + { + await queueSvc.DeliverQueue.EnqueueAsync(new DeliverJobData + { + RecipientHost = new Uri(relay.Inbox).Host, + InboxUrl = relay.Inbox, + Payload = payload, + ContentType = "application/activity+json", + UserId = jobData.ActorId + }); + } + } } } diff --git a/Iceshrimp.Backend/Core/Services/RelayService.cs b/Iceshrimp.Backend/Core/Services/RelayService.cs new file mode 100644 index 00000000..f33ad58c --- /dev/null +++ b/Iceshrimp.Backend/Core/Services/RelayService.cs @@ -0,0 +1,75 @@ +using Iceshrimp.Backend.Core.Database; +using Iceshrimp.Backend.Core.Database.Tables; +using Iceshrimp.Backend.Core.Helpers; +using Iceshrimp.Backend.Core.Middleware; +using Microsoft.EntityFrameworkCore; + +namespace Iceshrimp.Backend.Core.Services; + +public class RelayService( + DatabaseContext db, + SystemUserService systemUserSvc, + ActivityPub.ActivityRenderer activityRenderer, + ActivityPub.ActivityDeliverService deliverSvc, + ActivityPub.UserRenderer userRenderer +) +{ + public async Task SubscribeToRelay(string uri) + { + uri = new Uri(uri).AbsoluteUri; + if (await db.Relays.AnyAsync(p => p.Inbox == uri)) return; + + var relay = new Relay + { + Id = IdHelpers.GenerateSlowflakeId(), + Inbox = uri, + Status = Relay.RelayStatus.Requesting + }; + + db.Add(relay); + await db.SaveChangesAsync(); + + var actor = await systemUserSvc.GetRelayActorAsync(); + var activity = activityRenderer.RenderFollow(actor, relay); + await deliverSvc.DeliverToAsync(activity, actor, uri); + } + + public async Task UnsubscribeFromRelay(Relay relay) + { + var actor = await systemUserSvc.GetRelayActorAsync(); + var follow = activityRenderer.RenderFollow(actor, relay); + var activity = activityRenderer.RenderUndo(userRenderer.RenderLite(actor), follow); + await deliverSvc.DeliverToAsync(activity, actor, relay.Inbox); + + db.Remove(relay); + await db.SaveChangesAsync(); + } + + public async Task HandleAccept(User actor, string id) + { + // @formatter:off + if (await db.Relays.FirstOrDefaultAsync(p => p.Id == id) is not { } relay) + throw GracefulException.UnprocessableEntity($"Relay with id {id} was not found"); + if (relay.Inbox != new Uri(actor.Inbox ?? throw new Exception("Relay actor must have an inbox")).AbsoluteUri) + throw GracefulException.UnprocessableEntity($"Relay inbox ({relay.Inbox}) does not match relay actor inbox ({actor.Inbox})"); + // @formatter:on + + relay.Status = Relay.RelayStatus.Accepted; + actor.IsRelayActor = true; + await db.SaveChangesAsync(); + } + + public async Task HandleReject(User actor, string id) + { + // @formatter:off + if (db.Relays.FirstOrDefault(p => p.Id == id) is not { } relay) + throw GracefulException.UnprocessableEntity($"Relay with id {id} was not found"); + if (relay.Inbox != new Uri(actor.Inbox ?? throw new Exception("Relay actor must have an inbox")).AbsoluteUri) + throw GracefulException.UnprocessableEntity($"Relay inbox ({relay.Inbox}) does not match relay actor inbox ({actor.Inbox})"); + // @formatter:on + + relay.Status = Relay.RelayStatus.Rejected; + actor.IsRelayActor = true; + await db.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/Iceshrimp.Backend/Core/Services/SystemUserService.cs b/Iceshrimp.Backend/Core/Services/SystemUserService.cs index 8276b6af..c38b84f0 100644 --- a/Iceshrimp.Backend/Core/Services/SystemUserService.cs +++ b/Iceshrimp.Backend/Core/Services/SystemUserService.cs @@ -79,7 +79,8 @@ public class SystemUserService(ILogger logger, DatabaseContex IsAdmin = false, IsLocked = true, IsExplorable = false, - IsBot = true + IsBot = true, + IsSystem = true }; var userKeypair = new UserKeypair diff --git a/Iceshrimp.Backend/configuration.ini b/Iceshrimp.Backend/configuration.ini index c4ed7d7f..fe346f9f 100644 --- a/Iceshrimp.Backend/configuration.ini +++ b/Iceshrimp.Backend/configuration.ini @@ -27,10 +27,10 @@ CharacterLimit = 8192 ;; It is highly recommend you keep this enabled if you intend to use block- or allowlist federation AuthorizedFetch = true -;; Whether to attach LD signatures to outgoing activities +;; Whether to attach LD signatures to outgoing activities. Outgoing relayed activities get signed regardless of this option. AttachLdSignatures = false -;; Whether to accept activities signed using LD signatures +;; Whether to accept activities signed using LD signatures. Needs to be enabled for relayed activities to be accepted. AcceptLdSignatures = false ;; Whether to allow requests to IPv4 & IPv6 loopback addresses diff --git a/Iceshrimp.Shared/Schemas/Web/RelaySchemas.cs b/Iceshrimp.Shared/Schemas/Web/RelaySchemas.cs new file mode 100644 index 00000000..747350d8 --- /dev/null +++ b/Iceshrimp.Shared/Schemas/Web/RelaySchemas.cs @@ -0,0 +1,23 @@ +namespace Iceshrimp.Shared.Schemas.Web; + +public class RelaySchemas +{ + public enum RelayStatus + { + Requesting = 0, + Accepted = 1, + Rejected = 2 + } + + public class RelayResponse + { + public required string Id { get; set; } + public required string Inbox { get; set; } + public required RelayStatus Status { get; set; } + } + + public class RelayRequest + { + public required string Inbox { get; set; } + } +} \ No newline at end of file