[backend/masto-client] Add push notification support (ISH-182)

This commit is contained in:
Laura Hausmann 2024-03-14 18:53:54 +01:00
parent 3ab36f7d70
commit a623b870cd
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
20 changed files with 12556 additions and 37 deletions

View file

@ -1,6 +1,7 @@
using System.Net.Mime; using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes; using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas; using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database; using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables; using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions; using Iceshrimp.Backend.Core.Extensions;
@ -30,7 +31,7 @@ public class AuthController(DatabaseContext db) : ControllerBase
var res = new AuthSchemas.VerifyAppCredentialsResponse var res = new AuthSchemas.VerifyAppCredentialsResponse
{ {
App = token.App, VapidKey = null //FIXME App = token.App, VapidKey = Constants.VapidPublicKey
}; };
return Ok(res); return Ok(res);
@ -80,7 +81,7 @@ public class AuthController(DatabaseContext db) : ControllerBase
var res = new AuthSchemas.RegisterAppResponse var res = new AuthSchemas.RegisterAppResponse
{ {
App = app, VapidKey = null //FIXME App = app, VapidKey = Constants.VapidPublicKey
}; };
return Ok(res); return Ok(res);

View file

@ -64,12 +64,12 @@ public class NotificationController(DatabaseContext db, NotificationRenderer not
[Authorize("read:notifications")] [Authorize("read:notifications")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<NotificationEntity>))] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<NotificationEntity>))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> GetNotification(string id) public async Task<IActionResult> GetNotification(long id)
{ {
var user = HttpContext.GetUserOrFail(); var user = HttpContext.GetUserOrFail();
var notification = await db.Notifications var notification = await db.Notifications
.IncludeCommonProperties() .IncludeCommonProperties()
.Where(p => p.Notifiee == user && p.Id == id) .Where(p => p.Notifiee == user && p.MastoId == id)
.EnsureNoteVisibilityFor(p => p.Note, user) .EnsureNoteVisibilityFor(p => p.Note, user)
.PrecomputeNoteVisibilities(user) .PrecomputeNoteVisibilities(user)
.FirstOrDefaultAsync() ?? .FirstOrDefaultAsync() ??

View file

@ -0,0 +1,171 @@
using System.Net.Mime;
using Iceshrimp.Backend.Controllers.Mastodon.Attributes;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Middleware;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace Iceshrimp.Backend.Controllers.Mastodon;
[MastodonApiController]
[Route("/api/v1/push/subscription")]
[Authenticate]
[Authorize("push")]
[EnableRateLimiting("sliding")]
[EnableCors("mastodon")]
[Produces(MediaTypeNames.Application.Json)]
public class PushController(DatabaseContext db) : ControllerBase
{
[HttpPost]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PushSchemas.PushSubscription))]
public async Task<IActionResult> RegisterSubscription([FromHybrid] PushSchemas.RegisterPushRequest request)
{
var token = HttpContext.GetOauthToken();
if (token == null) throw GracefulException.Unauthorized("The access token is invalid");
var pushSubscription = await db.PushSubscriptions.FirstOrDefaultAsync(p => p.OauthToken == token);
if (pushSubscription == null)
{
pushSubscription = new PushSubscription
{
Id = IdHelpers.GenerateSlowflakeId(),
CreatedAt = DateTime.UtcNow,
Endpoint = request.Subscription.Endpoint,
User = token.User,
OauthToken = token,
PublicKey = request.Subscription.Keys.PublicKey,
AuthSecret = request.Subscription.Keys.AuthSecret,
Types = GetTypes(request.Data.Alerts),
Policy = GetPolicy(request.Data.Policy)
};
await db.AddAsync(pushSubscription);
await db.SaveChangesAsync();
}
var res = RenderSubscription(pushSubscription);
return Ok(res);
}
[HttpPut]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PushSchemas.PushSubscription))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> EditSubscription([FromHybrid] PushSchemas.EditPushRequest request)
{
var token = HttpContext.GetOauthToken();
if (token == null) throw GracefulException.Unauthorized("The access token is invalid");
var pushSubscription = await db.PushSubscriptions.FirstOrDefaultAsync(p => p.OauthToken == token) ??
throw GracefulException.RecordNotFound();
pushSubscription.Types = GetTypes(request.Data.Alerts);
pushSubscription.Policy = GetPolicy(request.Data.Policy);
await db.SaveChangesAsync();
var res = RenderSubscription(pushSubscription);
return Ok(res);
}
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(PushSchemas.PushSubscription))]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(MastodonErrorResponse))]
public async Task<IActionResult> GetSubscription()
{
var token = HttpContext.GetOauthToken();
if (token == null) throw GracefulException.Unauthorized("The access token is invalid");
var pushSubscription = await db.PushSubscriptions.FirstOrDefaultAsync(p => p.OauthToken == token) ??
throw GracefulException.RecordNotFound();
var res = RenderSubscription(pushSubscription);
return Ok(res);
}
[HttpDelete]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(object))]
public async Task<IActionResult> DeleteSubscription()
{
var token = HttpContext.GetOauthToken();
if (token == null) throw GracefulException.Unauthorized("The access token is invalid");
await db.PushSubscriptions.Where(p => p.OauthToken == token).ExecuteDeleteAsync();
return Ok(new object());
}
private PushSubscription.PushPolicy GetPolicy(string policy)
{
return policy switch
{
"all" => PushSubscription.PushPolicy.All,
"follower" => PushSubscription.PushPolicy.Follower,
"followed" => PushSubscription.PushPolicy.Followed,
"none" => PushSubscription.PushPolicy.None,
_ => throw GracefulException.BadRequest("Unknown push policy")
};
}
private string GetPolicyString(PushSubscription.PushPolicy policy)
{
return policy switch
{
PushSubscription.PushPolicy.All => "all",
PushSubscription.PushPolicy.Follower => "follower",
PushSubscription.PushPolicy.Followed => "followed",
PushSubscription.PushPolicy.None => "none",
_ => throw GracefulException.BadRequest("Unknown push policy")
};
}
private List<string> GetTypes(PushSchemas.Alerts alerts)
{
List<string> types = [];
if (alerts.Favourite)
types.Add("favourite");
if (alerts.Follow)
types.Add("follow");
if (alerts.Mention)
types.Add("mention");
if (alerts.Poll)
types.Add("poll");
if (alerts.Reblog)
types.Add("reblog");
if (alerts.Status)
types.Add("status");
if (alerts.Update)
types.Add("update");
if (alerts.FollowRequest)
types.Add("follow_request");
return types;
}
private PushSchemas.PushSubscription RenderSubscription(PushSubscription sub)
{
return new PushSchemas.PushSubscription
{
Id = sub.Id,
Endpoint = sub.Endpoint,
ServerKey = Constants.VapidPublicKey,
Policy = GetPolicyString(sub.Policy),
Alerts = new PushSchemas.Alerts
{
Favourite = sub.Types.Contains("favourite"),
Follow = sub.Types.Contains("follow"),
Mention = sub.Types.Contains("mention"),
Poll = sub.Types.Contains("poll"),
Reblog = sub.Types.Contains("reblog"),
Status = sub.Types.Contains("status"),
Update = sub.Types.Contains("update"),
FollowRequest = sub.Types.Contains("follow_request")
},
};
}
}

View file

@ -28,7 +28,7 @@ public class NotificationRenderer(NoteRenderer noteRenderer, UserRenderer userRe
var res = new NotificationEntity var res = new NotificationEntity
{ {
Id = notification.Id, Id = notification.MastoId.ToString(),
Type = NotificationEntity.EncodeType(notification.Type), Type = NotificationEntity.EncodeType(notification.Type),
Note = note, Note = note,
Notifier = notifier, Notifier = notifier,

View file

@ -0,0 +1,102 @@
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
using JR = System.Text.Json.Serialization.JsonRequiredAttribute;
using B = Microsoft.AspNetCore.Mvc.BindPropertyAttribute;
namespace Iceshrimp.Backend.Controllers.Mastodon.Schemas;
public abstract class PushSchemas
{
public class PushSubscription
{
[J("id")] public required string Id { get; set; }
[J("endpoint")] public required string Endpoint { get; set; }
[J("server_key")] public required string ServerKey { get; set; }
[J("alerts")] public required Alerts Alerts { get; set; }
[J("policy")] public required string Policy { get; set; }
}
public class Alerts
{
[J("mention")] [B(Name = "mention")] public bool Mention { get; set; } = false;
[J("status")] [B(Name = "status")] public bool Status { get; set; } = false;
[J("reblog")] [B(Name = "reblog")] public bool Reblog { get; set; } = false;
[J("follow")] [B(Name = "follow")] public bool Follow { get; set; } = false;
[J("follow_request")]
[B(Name = "follow_request")]
public bool FollowRequest { get; set; } = false;
[J("favourite")]
[B(Name = "favourite")]
public bool Favourite { get; set; } = false;
[J("poll")] [B(Name = "poll")] public bool Poll { get; set; } = false;
[J("update")] [B(Name = "update")] public bool Update { get; set; } = false;
}
public class RegisterPushRequest
{
[J("subscription")]
[JR]
[B(Name = "subscription")]
public required Subscription Subscription { get; set; }
[J("data")] [B(Name = "data")] public RegisterPushRequestData Data { get; set; } = new();
}
public class RegisterPushRequestData
{
[J("alerts")] [B(Name = "alerts")] public Alerts Alerts { get; set; } = new();
[J("policy")] [B(Name = "policy")] public string Policy { get; set; } = "all";
}
public class EditPushRequest
{
[J("policy")]
[B(Name = "policy")]
public string Policy
{
get => Data.Policy;
set => Data.Policy = value;
}
[J("data")] [B(Name = "data")] public RegisterPushRequestData Data { get; set; } = new();
}
public class EditPushRequestData
{
[J("alerts")] [B(Name = "alerts")] public Alerts Alerts { get; set; } = new();
[J("policy")] [B(Name = "policy")] public string Policy { get; set; } = "all";
}
public class Subscription
{
[J("endpoint")]
[JR]
[B(Name = "endpoint")]
public required string Endpoint { get; set; }
[J("keys")] [JR] [B(Name = "keys")] public required Keys Keys { get; set; }
}
public class Keys
{
[J("p256dh")]
[JR]
[B(Name = "p256dh")]
public required string PublicKey { get; set; }
[J("auth")] [JR] [B(Name = "auth")] public required string AuthSecret { get; set; }
}
public class PushNotification
{
[J("access_token")] public required string AccessToken { get; set; }
[J("notification_id")] public required long NotificationId { get; set; }
[J("notification_type")] public required string NotificationType { get; set; }
[J("icon")] public required string IconUrl { get; set; }
[J("title")] public required string Title { get; set; }
[J("body")] public required string Body { get; set; }
[J("preferred_locale")] public string PreferredLocale => "en";
}
}

View file

@ -11,6 +11,12 @@ public static class Constants
public const string MisskeyNs = "https://misskey-hub.net/ns"; public const string MisskeyNs = "https://misskey-hub.net/ns";
public static readonly string[] SystemUsers = ["instance.actor", "relay.actor"]; public static readonly string[] SystemUsers = ["instance.actor", "relay.actor"];
// TODO: reference runtime keys, these are just for initial testing
public const string VapidPrivateKey = "qmVGBbXSNVCmcZewcDr04MP7t1gvOFc2aQcThx3rD1A";
public const string VapidPublicKey =
"BEoyQxKBnLn8N1eLmh1va4Al7Azh2LJrfsp7w-PYb4sNSPG1C_SClRUpqw59dwQas3y8yEnFPE7MyGsMg1SItmE";
public static readonly string[] BrowserSafeMimeTypes = public static readonly string[] BrowserSafeMimeTypes =
[ [
"image/png", "image/png",

View file

@ -66,6 +66,7 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
public virtual DbSet<RenoteMuting> RenoteMutings { get; init; } = null!; public virtual DbSet<RenoteMuting> RenoteMutings { get; init; } = null!;
public virtual DbSet<Session> Sessions { get; init; } = null!; public virtual DbSet<Session> Sessions { get; init; } = null!;
public virtual DbSet<SwSubscription> SwSubscriptions { get; init; } = null!; public virtual DbSet<SwSubscription> SwSubscriptions { get; init; } = null!;
public virtual DbSet<PushSubscription> PushSubscriptions { get; init; } = null!;
public virtual DbSet<UsedUsername> UsedUsernames { get; init; } = null!; public virtual DbSet<UsedUsername> UsedUsernames { get; init; } = null!;
public virtual DbSet<User> Users { get; init; } = null!; public virtual DbSet<User> Users { get; init; } = null!;
public virtual DbSet<UserGroup> UserGroups { get; init; } = null!; public virtual DbSet<UserGroup> UserGroups { get; init; } = null!;
@ -105,6 +106,7 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
dataSourceBuilder.MapEnum<Relay.RelayStatus>(); dataSourceBuilder.MapEnum<Relay.RelayStatus>();
dataSourceBuilder.MapEnum<UserProfile.UserProfileFFVisibility>(); dataSourceBuilder.MapEnum<UserProfile.UserProfileFFVisibility>();
dataSourceBuilder.MapEnum<Marker.MarkerType>(); dataSourceBuilder.MapEnum<Marker.MarkerType>();
dataSourceBuilder.MapEnum<PushSubscription.PushPolicy>();
dataSourceBuilder.EnableDynamicJson(); dataSourceBuilder.EnableDynamicJson();
@ -128,6 +130,7 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
.HasPostgresEnum<Relay.RelayStatus>() .HasPostgresEnum<Relay.RelayStatus>()
.HasPostgresEnum<UserProfile.UserProfileFFVisibility>() .HasPostgresEnum<UserProfile.UserProfileFFVisibility>()
.HasPostgresEnum<Marker.MarkerType>() .HasPostgresEnum<Marker.MarkerType>()
.HasPostgresEnum<PushSubscription.PushPolicy>()
.HasPostgresExtension("pg_trgm"); .HasPostgresExtension("pg_trgm");
modelBuilder modelBuilder
@ -946,6 +949,20 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
modelBuilder.Entity<PushSubscription>(entity =>
{
entity.Property(e => e.Types).HasDefaultValueSql("'{}'::character varying[]");
entity.Property(e => e.Policy).HasDefaultValue(PushSubscription.PushPolicy.All);
entity.HasOne(d => d.User)
.WithMany(p => p.PushSubscriptions)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(d => d.OauthToken)
.WithOne(p => p.PushSubscription)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<UsedUsername>(); modelBuilder.Entity<UsedUsername>();
modelBuilder.Entity<User>(entity => modelBuilder.Entity<User>(entity =>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,75 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
public partial class AddPushSubscriptionTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "masto_id",
table: "notification",
type: "bigint",
nullable: false,
defaultValue: 0L)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
migrationBuilder.CreateTable(
name: "push_subscription",
columns: table => new
{
id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
createdAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
userId = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
oauthTokenId = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
endpoint = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
auth = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
publickey = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_push_subscription", x => x.id);
table.ForeignKey(
name: "FK_push_subscription_oauth_token_oauthTokenId",
column: x => x.oauthTokenId,
principalTable: "oauth_token",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_push_subscription_user_userId",
column: x => x.userId,
principalTable: "user",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_push_subscription_oauthTokenId",
table: "push_subscription",
column: "oauthTokenId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_push_subscription_userId",
table: "push_subscription",
column: "userId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "push_subscription");
migrationBuilder.DropColumn(
name: "masto_id",
table: "notification");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,80 @@
using System.Collections.Generic;
using Iceshrimp.Backend.Core.Database.Tables;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
public partial class AddPushSubscriptionColumns : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:Enum:antenna_src_enum", "home,all,users,list,group,instances")
.Annotation("Npgsql:Enum:marker_type_enum", "home,notifications")
.Annotation("Npgsql:Enum:note_visibility_enum", "public,home,followers,specified")
.Annotation("Npgsql:Enum:notification_type_enum", "follow,mention,reply,renote,quote,like,reaction,pollVote,pollEnded,receiveFollowRequest,followRequestAccepted,groupInvited,app,edit,bite")
.Annotation("Npgsql:Enum:page_visibility_enum", "public,followers,specified")
.Annotation("Npgsql:Enum:push_subscription_policy_enum", "all,followed,follower,none")
.Annotation("Npgsql:Enum:relay_status_enum", "requesting,accepted,rejected")
.Annotation("Npgsql:Enum:user_profile_ffvisibility_enum", "public,followers,private")
.Annotation("Npgsql:PostgresExtension:pg_trgm", ",,")
.OldAnnotation("Npgsql:Enum:antenna_src_enum", "home,all,users,list,group,instances")
.OldAnnotation("Npgsql:Enum:marker_type_enum", "home,notifications")
.OldAnnotation("Npgsql:Enum:note_visibility_enum", "public,home,followers,specified")
.OldAnnotation("Npgsql:Enum:notification_type_enum", "follow,mention,reply,renote,quote,like,reaction,pollVote,pollEnded,receiveFollowRequest,followRequestAccepted,groupInvited,app,edit,bite")
.OldAnnotation("Npgsql:Enum:page_visibility_enum", "public,followers,specified")
.OldAnnotation("Npgsql:Enum:relay_status_enum", "requesting,accepted,rejected")
.OldAnnotation("Npgsql:Enum:user_profile_ffvisibility_enum", "public,followers,private")
.OldAnnotation("Npgsql:PostgresExtension:pg_trgm", ",,");
migrationBuilder.AddColumn<PushSubscription.PushPolicy>(
name: "policy",
table: "push_subscription",
type: "push_subscription_policy_enum",
nullable: false,
defaultValue: PushSubscription.PushPolicy.All);
migrationBuilder.AddColumn<List<string>>(
name: "types",
table: "push_subscription",
type: "character varying(32)[]",
nullable: false,
defaultValueSql: "'{}'::character varying[]");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "policy",
table: "push_subscription");
migrationBuilder.DropColumn(
name: "types",
table: "push_subscription");
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:Enum:antenna_src_enum", "home,all,users,list,group,instances")
.Annotation("Npgsql:Enum:marker_type_enum", "home,notifications")
.Annotation("Npgsql:Enum:note_visibility_enum", "public,home,followers,specified")
.Annotation("Npgsql:Enum:notification_type_enum", "follow,mention,reply,renote,quote,like,reaction,pollVote,pollEnded,receiveFollowRequest,followRequestAccepted,groupInvited,app,edit,bite")
.Annotation("Npgsql:Enum:page_visibility_enum", "public,followers,specified")
.Annotation("Npgsql:Enum:relay_status_enum", "requesting,accepted,rejected")
.Annotation("Npgsql:Enum:user_profile_ffvisibility_enum", "public,followers,private")
.Annotation("Npgsql:PostgresExtension:pg_trgm", ",,")
.OldAnnotation("Npgsql:Enum:antenna_src_enum", "home,all,users,list,group,instances")
.OldAnnotation("Npgsql:Enum:marker_type_enum", "home,notifications")
.OldAnnotation("Npgsql:Enum:note_visibility_enum", "public,home,followers,specified")
.OldAnnotation("Npgsql:Enum:notification_type_enum", "follow,mention,reply,renote,quote,like,reaction,pollVote,pollEnded,receiveFollowRequest,followRequestAccepted,groupInvited,app,edit,bite")
.OldAnnotation("Npgsql:Enum:page_visibility_enum", "public,followers,specified")
.OldAnnotation("Npgsql:Enum:push_subscription_policy_enum", "all,followed,follower,none")
.OldAnnotation("Npgsql:Enum:relay_status_enum", "requesting,accepted,rejected")
.OldAnnotation("Npgsql:Enum:user_profile_ffvisibility_enum", "public,followers,private")
.OldAnnotation("Npgsql:PostgresExtension:pg_trgm", ",,");
}
}
}

View file

@ -27,6 +27,7 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "note_visibility_enum", new[] { "public", "home", "followers", "specified" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "note_visibility_enum", new[] { "public", "home", "followers", "specified" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "notification_type_enum", new[] { "follow", "mention", "reply", "renote", "quote", "like", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "edit", "bite" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "notification_type_enum", new[] { "follow", "mention", "reply", "renote", "quote", "like", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "edit", "bite" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "page_visibility_enum", new[] { "public", "followers", "specified" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "page_visibility_enum", new[] { "public", "followers", "specified" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "push_subscription_policy_enum", new[] { "all", "followed", "follower", "none" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "relay_status_enum", new[] { "requesting", "accepted", "rejected" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "relay_status_enum", new[] { "requesting", "accepted", "rejected" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "user_profile_ffvisibility_enum", new[] { "public", "followers", "private" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "user_profile_ffvisibility_enum", new[] { "public", "followers", "private" });
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm"); NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
@ -2755,6 +2756,13 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnName("isRead") .HasColumnName("isRead")
.HasComment("Whether the notification was read."); .HasComment("Whether the notification was read.");
b.Property<long>("MastoId")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("masto_id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("MastoId"));
b.Property<string>("NoteId") b.Property<string>("NoteId")
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)")
@ -3309,6 +3317,70 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
b.ToTable("promo_read"); b.ToTable("promo_read");
}); });
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.PushSubscription", b =>
{
b.Property<string>("Id")
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("id");
b.Property<string>("AuthSecret")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("auth");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("createdAt");
b.Property<string>("Endpoint")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("endpoint");
b.Property<string>("OauthTokenId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("oauthTokenId");
b.Property<PushSubscription.PushPolicy>("Policy")
.ValueGeneratedOnAdd()
.HasColumnType("push_subscription_policy_enum")
.HasDefaultValue(PushSubscription.PushPolicy.All)
.HasColumnName("policy");
b.Property<string>("PublicKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("publickey");
b.Property<List<string>>("Types")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("character varying(32)[]")
.HasColumnName("types")
.HasDefaultValueSql("'{}'::character varying[]");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("userId");
b.HasKey("Id");
b.HasIndex("OauthTokenId")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("push_subscription");
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.RegistrationInvite", b => modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.RegistrationInvite", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@ -3510,7 +3582,7 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)")
.HasColumnName("id"); .HasColumnName("id");
b.Property<string>("Auth") b.Property<string>("AuthSecret")
.IsRequired() .IsRequired()
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("character varying(256)") .HasColumnType("character varying(256)")
@ -3526,7 +3598,7 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
.HasColumnType("character varying(512)") .HasColumnType("character varying(512)")
.HasColumnName("endpoint"); .HasColumnName("endpoint");
b.Property<string>("Publickey") b.Property<string>("PublicKey")
.IsRequired() .IsRequired()
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("character varying(128)") .HasColumnType("character varying(128)")
@ -5297,6 +5369,25 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.PushSubscription", b =>
{
b.HasOne("Iceshrimp.Backend.Core.Database.Tables.OauthToken", "OauthToken")
.WithOne("PushSubscription")
.HasForeignKey("Iceshrimp.Backend.Core.Database.Tables.PushSubscription", "OauthTokenId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Iceshrimp.Backend.Core.Database.Tables.User", "User")
.WithMany("PushSubscriptions")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("OauthToken");
b.Navigation("User");
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.RegistryItem", b => modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.RegistryItem", b =>
{ {
b.HasOne("Iceshrimp.Backend.Core.Database.Tables.User", "User") b.HasOne("Iceshrimp.Backend.Core.Database.Tables.User", "User")
@ -5626,6 +5717,11 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
b.Navigation("OauthTokens"); b.Navigation("OauthTokens");
}); });
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.OauthToken", b =>
{
b.Navigation("PushSubscription");
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.Page", b => modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.Page", b =>
{ {
b.Navigation("PageLikes"); b.Navigation("PageLikes");
@ -5715,6 +5811,8 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
b.Navigation("PromoReads"); b.Navigation("PromoReads");
b.Navigation("PushSubscriptions");
b.Navigation("RegistryItems"); b.Navigation("RegistryItems");
b.Navigation("RenoteMutingMutees"); b.Navigation("RenoteMutingMutees");

View file

@ -122,6 +122,10 @@ public class Notification : IEntity
[StringLength(32)] [StringLength(32)]
public string Id { get; set; } = null!; public string Id { get; set; } = null!;
[Column("masto_id")]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long MastoId { get; set; }
public Notification WithPrecomputedNoteVisibilities(bool reply, bool renote) public Notification WithPrecomputedNoteVisibilities(bool reply, bool renote)
{ {
Note = Note?.WithPrecomputedVisibilities(reply, renote); Note = Note?.WithPrecomputedVisibilities(reply, renote);

View file

@ -65,6 +65,9 @@ public class OauthToken
[InverseProperty(nameof(Tables.User.OauthTokens))] [InverseProperty(nameof(Tables.User.OauthTokens))]
public virtual User User { get; set; } = null!; public virtual User User { get; set; } = null!;
[InverseProperty(nameof(Tables.PushSubscription.OauthToken))]
public virtual PushSubscription? PushSubscription { get; set; }
[Column("supportsHtmlFormatting")] public bool SupportsHtmlFormatting { get; set; } [Column("supportsHtmlFormatting")] public bool SupportsHtmlFormatting { get; set; }
[Column("autoDetectQuotes")] public bool AutoDetectQuotes { get; set; } [Column("autoDetectQuotes")] public bool AutoDetectQuotes { get; set; }

View file

@ -0,0 +1,57 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using NpgsqlTypes;
namespace Iceshrimp.Backend.Core.Database.Tables;
[Table("push_subscription")]
[Index("UserId")]
[Index("OauthTokenId", IsUnique = true)]
public class PushSubscription
{
[PgName("push_subscription_policy_enum")]
public enum PushPolicy
{
[PgName("all")] All,
[PgName("followed")] Followed,
[PgName("follower")] Follower,
[PgName("none")] None,
}
[Key]
[Column("id")]
[StringLength(32)]
public string Id { get; set; } = null!;
[Column("createdAt")] public DateTime CreatedAt { get; set; }
[Column("userId")] [StringLength(32)] public string UserId { get; set; } = null!;
[Column("oauthTokenId")]
[StringLength(32)]
public string OauthTokenId { get; set; } = null!;
[Column("endpoint")]
[StringLength(512)]
public string Endpoint { get; set; } = null!;
[Column("auth")] [StringLength(256)] public string AuthSecret { get; set; } = null!;
[Column("publickey")]
[StringLength(128)]
public string PublicKey { get; set; } = null!;
[Column("types", TypeName = "character varying(32)[]")]
public List<string> Types = null!;
[Column("policy")] public PushPolicy Policy { get; set; }
[ForeignKey("UserId")]
[InverseProperty(nameof(Tables.User.PushSubscriptions))]
public virtual User User { get; set; } = null!;
[ForeignKey("OauthTokenId")]
[InverseProperty(nameof(Tables.OauthToken.PushSubscription))]
public virtual OauthToken OauthToken { get; set; } = null!;
}

View file

@ -21,11 +21,11 @@ public class SwSubscription
[StringLength(512)] [StringLength(512)]
public string Endpoint { get; set; } = null!; public string Endpoint { get; set; } = null!;
[Column("auth")] [StringLength(256)] public string Auth { get; set; } = null!; [Column("auth")] [StringLength(256)] public string AuthSecret { get; set; } = null!;
[Column("publickey")] [Column("publickey")]
[StringLength(128)] [StringLength(128)]
public string Publickey { get; set; } = null!; public string PublicKey { get; set; } = null!;
[Column("sendReadMessage")] public bool SendReadMessage { get; set; } [Column("sendReadMessage")] public bool SendReadMessage { get; set; }

View file

@ -440,6 +440,9 @@ public class User : IEntity
[InverseProperty(nameof(SwSubscription.User))] [InverseProperty(nameof(SwSubscription.User))]
public virtual ICollection<SwSubscription> SwSubscriptions { get; set; } = new List<SwSubscription>(); public virtual ICollection<SwSubscription> SwSubscriptions { get; set; } = new List<SwSubscription>();
[InverseProperty(nameof(PushSubscription.User))]
public virtual ICollection<PushSubscription> PushSubscriptions { get; set; } = new List<PushSubscription>();
[InverseProperty(nameof(UserGroupInvitation.User))] [InverseProperty(nameof(UserGroupInvitation.User))]
public virtual ICollection<UserGroupInvitation> UserGroupInvitations { get; set; } = public virtual ICollection<UserGroupInvitation> UserGroupInvitations { get; set; } =
new List<UserGroupInvitation>(); new List<UserGroupInvitation>();

View file

@ -76,12 +76,14 @@ public static class ServiceExtensions
.AddSingleton<RequestBufferingMiddleware>() .AddSingleton<RequestBufferingMiddleware>()
.AddSingleton<AuthorizationMiddleware>() .AddSingleton<AuthorizationMiddleware>()
.AddSingleton<RequestVerificationMiddleware>() .AddSingleton<RequestVerificationMiddleware>()
.AddSingleton<RequestDurationMiddleware>(); .AddSingleton<RequestDurationMiddleware>()
.AddSingleton<PushService>();
// Hosted services = long running background tasks // Hosted services = long running background tasks
// Note: These need to be added as a singleton as well to ensure data consistency // Note: These need to be added as a singleton as well to ensure data consistency
services.AddHostedService<CronService>(provider => provider.GetRequiredService<CronService>()); services.AddHostedService<CronService>(provider => provider.GetRequiredService<CronService>());
services.AddHostedService<QueueService>(provider => provider.GetRequiredService<QueueService>()); services.AddHostedService<QueueService>(provider => provider.GetRequiredService<QueueService>());
services.AddHostedService<PushService>(provider => provider.GetRequiredService<PushService>());
} }
public static void ConfigureServices(this IServiceCollection services, IConfiguration configuration) public static void ConfigureServices(this IServiceCollection services, IConfiguration configuration)

View file

@ -0,0 +1,149 @@
using System.Net;
using System.Text.Json;
using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas;
using Iceshrimp.Backend.Controllers.Mastodon.Schemas.Entities;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Middleware;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using WebPush;
using PushSubscription = Iceshrimp.Backend.Core.Database.Tables.PushSubscription;
using WebPushSubscription = WebPush.PushSubscription;
namespace Iceshrimp.Backend.Core.Services;
public class PushService(
EventService eventSvc,
ILogger<PushService> logger,
IServiceScopeFactory scopeFactory,
HttpClient httpClient,
IOptions<Config.InstanceSection> config
) : BackgroundService
{
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
eventSvc.Notification += MastodonPushHandler;
//TODO: eventSvc.Notification += WebPushHandler;
return Task.CompletedTask;
}
private async void MastodonPushHandler(object? _, Notification notification)
{
try
{
var provider = scopeFactory.CreateScope().ServiceProvider;
var db = provider.GetRequiredService<DatabaseContext>();
var subscriptions = await db.PushSubscriptions.Where(p => p.User == notification.Notifiee)
.Include(pushSubscription => pushSubscription.OauthToken)
.Where(p => p.Types.Contains(NotificationEntity.EncodeType(notification.Type)))
.ToListAsync();
if (subscriptions.Count == 0)
return;
var isSelf = notification.Notifier == notification.Notifiee;
var followed = subscriptions.All(p => p.Policy != PushSubscription.PushPolicy.Followed) ||
isSelf ||
await db.Followings.AnyAsync(p => p.Follower == notification.Notifiee &&
p.Followee == notification.Notifier);
var follower = subscriptions.All(p => p.Policy != PushSubscription.PushPolicy.Follower) ||
isSelf ||
await db.Followings.AnyAsync(p => p.Follower == notification.Notifier &&
p.Followee == notification.Notifiee);
try
{
var renderer = provider.GetRequiredService<NotificationRenderer>();
var rendered = await renderer.RenderAsync(notification, notification.Notifiee);
var name = rendered.Notifier.DisplayName;
var subject = rendered.Type switch
{
"favourite" => $"{name} favorited your post",
"follow" => $"{name} is now following you",
"follow_request" => $"Pending follower: {name}",
"mention" => $"You were mentioned by {name}",
"poll" => $"A poll by {name} has ended",
"reblog" => $"{name} boosted your post",
"status" => $"{name} just posted",
"update" => $"{name} edited a post",
_ => $"New notification from {name}"
};
var body = "";
if (notification.Note != null)
body = notification.Note.Cw ?? notification.Note.Text ?? "";
body = body.Trim().Truncate(140).TrimEnd();
if (body.Length > 137)
body = body.Truncate(137).TrimEnd() + "...";
var client = new WebPushClient(httpClient);
client.SetVapidDetails(new VapidDetails($"https://{config.Value.WebDomain}",
Constants.VapidPublicKey, Constants.VapidPrivateKey));
var matchingSubscriptions =
from subscription in subscriptions
where subscription.Policy != PushSubscription.PushPolicy.Followed || followed
where subscription.Policy != PushSubscription.PushPolicy.Follower || follower
where subscription.Policy != PushSubscription.PushPolicy.None || isSelf
select subscription;
foreach (var subscription in matchingSubscriptions)
{
try
{
var res = new PushSchemas.PushNotification
{
AccessToken = subscription.OauthToken.Token,
NotificationType = rendered.Type,
NotificationId = long.Parse(rendered.Id),
IconUrl = rendered.Notifier.AvatarUrl,
Title = subject,
Body = body
};
var sub = new WebPushSubscription
{
Endpoint = subscription.Endpoint,
P256DH = subscription.PublicKey,
Auth = subscription.AuthSecret,
PushMode = PushMode.AesGcm
};
await client.SendNotificationAsync(sub, JsonSerializer.Serialize(res));
}
catch (Exception e)
{
switch (e)
{
case WebPushException { StatusCode: HttpStatusCode.Gone }:
await db.PushSubscriptions.Where(p => p.Id == subscription.Id).ExecuteDeleteAsync();
break;
case WebPushException we:
logger.LogDebug("Push notification delivery failed: {e}", we.Message);
break;
default:
logger.LogDebug("Push notification delivery threw exception: {e}", e);
break;
}
}
}
}
catch (GracefulException)
{
// Unsupported notification type
}
}
catch (Exception e)
{
logger.LogError("Event handler MastodonPushHandler threw exception: {e}", e);
}
}
}

View file

@ -16,48 +16,49 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Amazon.S3" Version="0.31.2"/> <PackageReference Include="Amazon.S3" Version="0.31.2" />
<PackageReference Include="AngleSharp" Version="1.1.0"/> <PackageReference Include="AngleSharp" Version="1.1.0" />
<PackageReference Include="Asp.Versioning.Http" Version="8.0.0"/> <PackageReference Include="Asp.Versioning.Http" Version="8.0.0" />
<PackageReference Include="AsyncKeyedLock" Version="6.3.4"/> <PackageReference Include="AsyncKeyedLock" Version="6.3.4" />
<PackageReference Include="Blurhash.ImageSharp" Version="3.0.0"/> <PackageReference Include="Blurhash.ImageSharp" Version="3.0.0" />
<PackageReference Include="cuid.net" Version="5.0.2"/> <PackageReference Include="cuid.net" Version="5.0.2" />
<PackageReference Include="dotNetRdf.Core" Version="3.2.5-iceshrimp" /> <PackageReference Include="dotNetRdf.Core" Version="3.2.5-iceshrimp" />
<PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.0.0"/> <PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.0.0" />
<PackageReference Include="EntityFrameworkCore.Projectables" Version="3.0.4"/> <PackageReference Include="EntityFrameworkCore.Projectables" Version="3.0.4" />
<PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0"/> <PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" />
<PackageReference Include="libsodium" Version="1.0.18.4" /> <PackageReference Include="libsodium" Version="1.0.18.4" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="8.0.1"/> <PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="8.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.0"/> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.0"/> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.0">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.1"/> <PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Ini" Version="8.0.0"/> <PackageReference Include="Microsoft.Extensions.Configuration.Ini" Version="8.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0"/> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
<PackageReference Include="protobuf-net" Version="3.2.30"/> <PackageReference Include="protobuf-net" Version="3.2.30" />
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.1"/> <PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.1" />
<PackageReference Include="StackExchange.Redis" Version="2.7.17"/> <PackageReference Include="StackExchange.Redis" Version="2.7.17" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0"/> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="System.IO.Hashing" Version="8.0.0"/> <PackageReference Include="System.IO.Hashing" Version="8.0.0" />
<PackageReference Include="Vite.AspNetCore" Version="1.11.0"/> <PackageReference Include="Vite.AspNetCore" Version="1.11.0" />
<PackageReference Include="WebPush" Version="1.0.24-iceshrimp" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<AdditionalFiles Include="Pages\Error.cshtml"/> <AdditionalFiles Include="Pages\Error.cshtml" />
<AdditionalFiles Include="Pages\Shared\_Layout.cshtml"/> <AdditionalFiles Include="Pages\Shared\_Layout.cshtml" />
<AdditionalFiles Include="Pages\_ViewImports.cshtml"/> <AdditionalFiles Include="Pages\_ViewImports.cshtml" />
<AdditionalFiles Include="Pages\_ViewStart.cshtml"/> <AdditionalFiles Include="Pages\_ViewStart.cshtml" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="wwwroot\.vite\manifest.json" CopyToPublishDirectory="PreserveNewest"/> <Content Include="wwwroot\.vite\manifest.json" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Remove="migrate.sql"/> <None Remove="migrate.sql" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>