Compare commits

...

5 commits

Author SHA1 Message Date
sam
57e1ec09c0
feat: link fediverse account to existing user 2024-12-04 01:49:03 +01:00
sam
03209e4028
chore(backend): clean imports 2024-12-03 20:05:24 +01:00
sam
9966656c0c
fix(backend): don't need [NotMapped] for these actually 2024-12-03 20:04:28 +01:00
sam
c20831f20d
feat(frontend): export ui 2024-12-03 20:02:09 +01:00
sam
74222ead45
feat(frontend): replace placeholder avatar with identicons
i don't actually know what the license on the kitten image is, and while
it's very unlikely, i don't want to get into legal trouble. it was only
ever supposed to be a temporary image, too.

identicons aren't the prettiest but at least they have a clear license
:3
2024-12-03 15:19:52 +01:00
40 changed files with 476 additions and 123 deletions

View file

@ -104,21 +104,9 @@ public class DiscordAuthController(
{ {
CheckRequirements(); CheckRequirements();
var existingAccounts = await db var state = await remoteAuthService.ValidateAddAccountRequestAsync(
.AuthMethods.Where(m => m.UserId == CurrentUser!.Id && m.AuthType == AuthType.Discord) CurrentUser!.Id,
.CountAsync(); AuthType.Discord
if (existingAccounts > AuthUtils.MaxAuthMethodsPerType)
{
throw new ApiError.BadRequest(
"Too many linked Discord accounts, maximum of 3 per account."
);
}
var state = HttpUtility.UrlEncode(
await keyCacheService.GenerateAddExtraAccountStateAsync(
AuthType.Discord,
CurrentUser!.Id
)
); );
var url = var url =
@ -138,12 +126,11 @@ public class DiscordAuthController(
{ {
CheckRequirements(); CheckRequirements();
var accountState = await keyCacheService.GetAddExtraAccountStateAsync(req.State); await remoteAuthService.ValidateAddAccountStateAsync(
if ( req.State,
accountState is not { AuthType: AuthType.Discord } CurrentUser!.Id,
|| accountState.UserId != CurrentUser!.Id AuthType.Discord
) );
throw new ApiError.BadRequest("Invalid state", "state", req.State);
var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code); var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code);
try try

View file

@ -1,5 +1,8 @@
using System.Net;
using EntityFramework.Exceptions.Common;
using Foxnouns.Backend.Database; using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services; using Foxnouns.Backend.Services;
using Foxnouns.Backend.Services.Auth; using Foxnouns.Backend.Services.Auth;
using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils;
@ -15,13 +18,14 @@ public class FediverseAuthController(
DatabaseContext db, DatabaseContext db,
FediverseAuthService fediverseAuthService, FediverseAuthService fediverseAuthService,
AuthService authService, AuthService authService,
RemoteAuthService remoteAuthService,
KeyCacheService keyCacheService KeyCacheService keyCacheService
) : ApiControllerBase ) : ApiControllerBase
{ {
private readonly ILogger _logger = logger.ForContext<FediverseAuthController>(); private readonly ILogger _logger = logger.ForContext<FediverseAuthController>();
[HttpGet] [HttpGet]
[ProducesResponseType<FediverseUrlResponse>(statusCode: StatusCodes.Status200OK)] [ProducesResponseType<AuthController.SingleUrlResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> GetFediverseUrlAsync( public async Task<IActionResult> GetFediverseUrlAsync(
[FromQuery] string instance, [FromQuery] string instance,
[FromQuery] bool forceRefresh = false [FromQuery] bool forceRefresh = false
@ -31,7 +35,7 @@ public class FediverseAuthController(
throw new ApiError.BadRequest("Not a valid domain.", "instance", instance); throw new ApiError.BadRequest("Not a valid domain.", "instance", instance);
var url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh); var url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh);
return Ok(new FediverseUrlResponse(url)); return Ok(new AuthController.SingleUrlResponse(url));
} }
[HttpPost("callback")] [HttpPost("callback")]
@ -118,9 +122,74 @@ public class FediverseAuthController(
return Ok(await authService.GenerateUserTokenAsync(user)); return Ok(await authService.GenerateUserTokenAsync(user));
} }
public record CallbackRequest(string Instance, string Code, string State); [HttpGet("add-account")]
[Authorize("*")]
public async Task<IActionResult> AddFediverseAccountAsync(
[FromQuery] string instance,
[FromQuery] bool forceRefresh = false
)
{
if (instance.Any(c => c is '@' or ':' or '/') || !instance.Contains('.'))
throw new ApiError.BadRequest("Not a valid domain.", "instance", instance);
private record FediverseUrlResponse(string Url); var state = await remoteAuthService.ValidateAddAccountRequestAsync(
CurrentUser!.Id,
AuthType.Fediverse,
instance
);
var url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh, state);
return Ok(new AuthController.SingleUrlResponse(url));
}
[HttpPost("add-account/callback")]
[Authorize("*")]
public async Task<IActionResult> AddAccountCallbackAsync([FromBody] CallbackRequest req)
{
await remoteAuthService.ValidateAddAccountStateAsync(
req.State,
CurrentUser!.Id,
AuthType.Fediverse,
req.Instance
);
var app = await fediverseAuthService.GetApplicationAsync(req.Instance);
var remoteUser = await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code);
try
{
var authMethod = await authService.AddAuthMethodAsync(
CurrentUser.Id,
AuthType.Fediverse,
remoteUser.Id,
remoteUser.Username,
app
);
_logger.Debug(
"Added new Fediverse auth method {AuthMethodId} to user {UserId}",
authMethod.Id,
CurrentUser.Id
);
return Ok(
new AuthController.AddOauthAccountResponse(
authMethod.Id,
AuthType.Fediverse,
authMethod.RemoteId,
$"{authMethod.RemoteUsername}@{app.Domain}"
)
);
}
catch (UniqueConstraintException)
{
throw new ApiError(
"That account is already linked.",
HttpStatusCode.BadRequest,
ErrorCode.AccountAlreadyLinked
);
}
}
public record CallbackRequest(string Instance, string Code, string State);
private record FediverseTicketData( private record FediverseTicketData(
Snowflake ApplicationId, Snowflake ApplicationId,

View file

@ -1,6 +1,5 @@
using Coravel.Queuing.Interfaces; using Coravel.Queuing.Interfaces;
using Foxnouns.Backend.Database; using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Jobs; using Foxnouns.Backend.Jobs;
using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Middleware;

View file

@ -1,5 +1,4 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Coravel.Mailer.Mail.Helpers;
using Coravel.Queuing.Interfaces; using Coravel.Queuing.Interfaces;
using EntityFramework.Exceptions.Common; using EntityFramework.Exceptions.Common;
using Foxnouns.Backend.Database; using Foxnouns.Backend.Database;

View file

@ -1,4 +1,3 @@
using System.ComponentModel.DataAnnotations.Schema;
using NodaTime; using NodaTime;
namespace Foxnouns.Backend.Database.Models; namespace Foxnouns.Backend.Database.Models;
@ -9,6 +8,5 @@ public class DataExport : BaseModel
public User User { get; init; } = null!; public User User { get; init; } = null!;
public required string Filename { get; init; } public required string Filename { get; init; }
[NotMapped]
public static readonly Duration Expiration = Duration.FromDays(15); public static readonly Duration Expiration = Duration.FromDays(15);
} }

View file

@ -1,5 +1,3 @@
using NodaTime;
namespace Foxnouns.Backend.Database.Models; namespace Foxnouns.Backend.Database.Models;
public class FediverseApplication : BaseModel public class FediverseApplication : BaseModel

View file

@ -55,10 +55,7 @@ public class User : BaseModel
public PreferenceSize Size { get; set; } public PreferenceSize Size { get; set; }
} }
[NotMapped]
public static readonly Duration DeleteAfter = Duration.FromDays(30); public static readonly Duration DeleteAfter = Duration.FromDays(30);
[NotMapped]
public static readonly Duration DeleteSuspendedAfter = Duration.FromDays(180); public static readonly Duration DeleteSuspendedAfter = Duration.FromDays(180);
} }

View file

@ -63,13 +63,14 @@ public static class KeyCacheExtensions
this KeyCacheService keyCacheService, this KeyCacheService keyCacheService,
AuthType authType, AuthType authType,
Snowflake userId, Snowflake userId,
string? instance = null,
CancellationToken ct = default CancellationToken ct = default
) )
{ {
var state = AuthUtils.RandomToken(); var state = AuthUtils.RandomToken();
await keyCacheService.SetKeyAsync( await keyCacheService.SetKeyAsync(
$"add_account:{state}", $"add_account:{state}",
new AddExtraAccountState(authType, userId), new AddExtraAccountState(authType, userId, instance),
Duration.FromDays(1), Duration.FromDays(1),
ct ct
); );
@ -93,4 +94,4 @@ public record RegisterEmailState(
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ExistingUserId [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ExistingUserId
); );
public record AddExtraAccountState(AuthType AuthType, Snowflake UserId); public record AddExtraAccountState(AuthType AuthType, Snowflake UserId, string? Instance = null);

View file

@ -12,6 +12,7 @@
<PackageReference Include="Coravel.Mailer" Version="5.0.1"/> <PackageReference Include="Coravel.Mailer" Version="5.0.1"/>
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3"/> <PackageReference Include="EFCore.NamingConventions" Version="8.0.3"/>
<PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.2"/> <PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.2"/>
<PackageReference Include="Humanizer.Core" Version="2.14.1"/>
<PackageReference Include="JetBrains.Annotations" Version="2024.2.0"/> <PackageReference Include="JetBrains.Annotations" Version="2024.2.0"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7"/> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7"/>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7"/> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7"/>

View file

@ -1,4 +1,3 @@
using System.Security.Cryptography;
using Coravel.Invocable; using Coravel.Invocable;
using Foxnouns.Backend.Database; using Foxnouns.Backend.Database;
using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Extensions;

View file

@ -1,4 +1,3 @@
using System.Security.Cryptography;
using Coravel.Invocable; using Coravel.Invocable;
using Foxnouns.Backend.Database; using Foxnouns.Backend.Database;
using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Extensions;

View file

@ -1,6 +1,5 @@
using System.Net; using System.Net;
using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace Foxnouns.Backend.Middleware; namespace Foxnouns.Backend.Middleware;

View file

@ -218,10 +218,11 @@ public class AuthService(
AuthType authType, AuthType authType,
string remoteId, string remoteId,
string? remoteUsername = null, string? remoteUsername = null,
FediverseApplication? app = null,
CancellationToken ct = default CancellationToken ct = default
) )
{ {
AssertValidAuthType(authType, null); AssertValidAuthType(authType, app);
// This is already checked when // This is already checked when
var currentCount = await db var currentCount = await db
@ -237,6 +238,7 @@ public class AuthService(
Id = snowflakeGenerator.GenerateSnowflake(), Id = snowflakeGenerator.GenerateSnowflake(),
AuthType = authType, AuthType = authType,
RemoteId = remoteId, RemoteId = remoteId,
FediverseApplicationId = app?.Id,
RemoteUsername = remoteUsername, RemoteUsername = remoteUsername,
UserId = userId, UserId = userId,
}; };

View file

@ -69,10 +69,11 @@ public partial class FediverseAuthService
private async Task<FediverseUser> GetMastodonUserAsync( private async Task<FediverseUser> GetMastodonUserAsync(
FediverseApplication app, FediverseApplication app,
string code, string code,
string state string? state = null
) )
{ {
await _keyCacheService.ValidateAuthStateAsync(state); if (state != null)
await _keyCacheService.ValidateAuthStateAsync(state);
var tokenResp = await _client.PostAsync( var tokenResp = await _client.PostAsync(
MastodonTokenUri(app.Domain), MastodonTokenUri(app.Domain),
@ -120,7 +121,8 @@ public partial class FediverseAuthService
private async Task<string> GenerateMastodonAuthUrlAsync( private async Task<string> GenerateMastodonAuthUrlAsync(
FediverseApplication app, FediverseApplication app,
bool forceRefresh bool forceRefresh,
string? state = null
) )
{ {
if (forceRefresh) if (forceRefresh)
@ -132,7 +134,7 @@ public partial class FediverseAuthService
app = await CreateMastodonApplicationAsync(app.Domain, existingAppId: app.Id); app = await CreateMastodonApplicationAsync(app.Domain, existingAppId: app.Id);
} }
var state = HttpUtility.UrlEncode(await _keyCacheService.GenerateAuthStateAsync()); state ??= HttpUtility.UrlEncode(await _keyCacheService.GenerateAuthStateAsync());
return $"https://{app.Domain}/oauth/authorize?response_type=code" return $"https://{app.Domain}/oauth/authorize?response_type=code"
+ $"&client_id={app.ClientId}" + $"&client_id={app.ClientId}"

View file

@ -37,10 +37,14 @@ public partial class FediverseAuthService
_client.DefaultRequestHeaders.Add("Accept", "application/json"); _client.DefaultRequestHeaders.Add("Accept", "application/json");
} }
public async Task<string> GenerateAuthUrlAsync(string instance, bool forceRefresh) public async Task<string> GenerateAuthUrlAsync(
string instance,
bool forceRefresh,
string? state = null
)
{ {
var app = await GetApplicationAsync(instance); var app = await GetApplicationAsync(instance);
return await GenerateAuthUrlAsync(app, forceRefresh); return await GenerateAuthUrlAsync(app, forceRefresh, state);
} }
// thank you, gargron and syuilo, for agreeing on a name for *once* in your lives, // thank you, gargron and syuilo, for agreeing on a name for *once* in your lives,
@ -96,12 +100,17 @@ public partial class FediverseAuthService
); );
} }
private async Task<string> GenerateAuthUrlAsync(FediverseApplication app, bool forceRefresh) => private async Task<string> GenerateAuthUrlAsync(
FediverseApplication app,
bool forceRefresh,
string? state = null
) =>
app.InstanceType switch app.InstanceType switch
{ {
FediverseInstanceType.MastodonApi => await GenerateMastodonAuthUrlAsync( FediverseInstanceType.MastodonApi => await GenerateMastodonAuthUrlAsync(
app, app,
forceRefresh forceRefresh,
state
), ),
FediverseInstanceType.MisskeyApi => throw new NotImplementedException(), FediverseInstanceType.MisskeyApi => throw new NotImplementedException(),
_ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null), _ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
@ -110,7 +119,7 @@ public partial class FediverseAuthService
public async Task<FediverseUser> GetRemoteFediverseUserAsync( public async Task<FediverseUser> GetRemoteFediverseUserAsync(
FediverseApplication app, FediverseApplication app,
string code, string code,
string state string? state = null
) => ) =>
app.InstanceType switch app.InstanceType switch
{ {

View file

@ -1,9 +1,21 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Web;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Utils;
using Humanizer;
using JetBrains.Annotations; using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
namespace Foxnouns.Backend.Services; namespace Foxnouns.Backend.Services.Auth;
public class RemoteAuthService(Config config, ILogger logger) public class RemoteAuthService(
Config config,
ILogger logger,
DatabaseContext db,
KeyCacheService keyCacheService
)
{ {
private readonly ILogger _logger = logger.ForContext<RemoteAuthService>(); private readonly ILogger _logger = logger.ForContext<RemoteAuthService>();
private readonly HttpClient _httpClient = new(); private readonly HttpClient _httpClient = new();
@ -76,4 +88,56 @@ public class RemoteAuthService(Config config, ILogger logger)
private record DiscordUserResponse(string id, string username); private record DiscordUserResponse(string id, string username);
public record RemoteUser(string Id, string Username); public record RemoteUser(string Id, string Username);
/// <summary>
/// Validates whether a user can still add a new account of the given AuthType, and throws an error if they can't.
/// </summary>
/// <param name="userId">The user to check.</param>
/// <param name="authType">The auth type to check.</param>
/// <param name="instance">The optional fediverse instance to generate a state for.</param>
/// <returns>A state for the given auth type and user ID.</returns>
/// <exception cref="ApiError.BadRequest">The given user can't add another account of this type.
/// This exception should not be caught by controller code.</exception>
public async Task<string> ValidateAddAccountRequestAsync(
Snowflake userId,
AuthType authType,
string? instance = null
)
{
var existingAccounts = await db
.AuthMethods.Where(m => m.UserId == userId && m.AuthType == authType)
.CountAsync();
if (existingAccounts > AuthUtils.MaxAuthMethodsPerType)
{
throw new ApiError.BadRequest(
$"Too many linked {authType.Humanize()} accounts, maximum of {AuthUtils.MaxAuthMethodsPerType} per account."
);
}
return HttpUtility.UrlEncode(
await keyCacheService.GenerateAddExtraAccountStateAsync(authType, userId, instance)
);
}
/// <summary>
/// Checks whether the given state is correct for the given user/auth type combination.
/// </summary>
/// <exception cref="ApiError.BadRequest">The state doesn't match.
/// This exception should not be caught by controller code.</exception>
public async Task ValidateAddAccountStateAsync(
string state,
Snowflake userId,
AuthType authType,
string? instance = null
)
{
var accountState = await keyCacheService.GetAddExtraAccountStateAsync(state);
if (
accountState == null
|| accountState.AuthType != authType
|| accountState.UserId != userId
|| (instance != null && accountState.Instance != instance)
)
throw new ApiError.BadRequest("Invalid state", "state", state);
}
} }

View file

@ -4,7 +4,6 @@ using Foxnouns.Backend.Utils;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json; using Newtonsoft.Json;
using NodaTime; using NodaTime;
using Org.BouncyCastle.Ocsp;
namespace Foxnouns.Backend.Services; namespace Foxnouns.Backend.Services;

View file

@ -127,6 +127,9 @@ public static partial class ValidationUtils
if (entries.Length > Limits.FieldEntriesLimit + 50) if (entries.Length > Limits.FieldEntriesLimit + 50)
return errors; return errors;
var customPreferenceIds =
customPreferences?.Keys.Select(id => id.ToString()).ToArray() ?? [];
foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))) foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx)))
{ {
switch (entry.Value.Length) switch (entry.Value.Length)
@ -159,8 +162,6 @@ public static partial class ValidationUtils
break; break;
} }
var customPreferenceIds = customPreferences?.Keys.Select(id => id.ToString()) ?? [];
if ( if (
!DefaultStatusOptions.Contains(entry.Status) !DefaultStatusOptions.Contains(entry.Status)
&& !customPreferenceIds.Contains(entry.Status) && !customPreferenceIds.Contains(entry.Status)
@ -203,6 +204,9 @@ public static partial class ValidationUtils
if (entries.Length > Limits.FieldEntriesLimit + 50) if (entries.Length > Limits.FieldEntriesLimit + 50)
return errors; return errors;
var customPreferenceIds =
customPreferences?.Keys.Select(id => id.ToString()).ToList() ?? [];
foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))) foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx)))
{ {
switch (entry.Value.Length) switch (entry.Value.Length)
@ -268,8 +272,6 @@ public static partial class ValidationUtils
} }
} }
var customPreferenceIds = customPreferences?.Keys.Select(id => id.ToString()) ?? [];
if ( if (
!DefaultStatusOptions.Contains(entry.Status) !DefaultStatusOptions.Contains(entry.Status)
&& !customPreferenceIds.Contains(entry.Status) && !customPreferenceIds.Contains(entry.Status)

View file

@ -45,6 +45,12 @@
"Npgsql": "8.0.1" "Npgsql": "8.0.1"
} }
}, },
"Humanizer.Core": {
"type": "Direct",
"requested": "[2.14.1, )",
"resolved": "2.14.1",
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
},
"JetBrains.Annotations": { "JetBrains.Annotations": {
"type": "Direct", "type": "Direct",
"requested": "[2024.2.0, )", "requested": "[2024.2.0, )",
@ -291,11 +297,6 @@
"Microsoft.EntityFrameworkCore.Relational": "8.0.0" "Microsoft.EntityFrameworkCore.Relational": "8.0.0"
} }
}, },
"Humanizer.Core": {
"type": "Transitive",
"resolved": "2.14.1",
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
},
"MailKit": { "MailKit": {
"type": "Transitive", "type": "Transitive",
"resolved": "2.5.1", "resolved": "2.5.1",

View file

@ -42,6 +42,7 @@
"bootstrap-icons": "^1.11.3", "bootstrap-icons": "^1.11.3",
"luxon": "^3.5.0", "luxon": "^3.5.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"minidenticons": "^4.2.1",
"pretty-bytes": "^6.1.1", "pretty-bytes": "^6.1.1",
"sanitize-html": "^2.13.1", "sanitize-html": "^2.13.1",
"svelte-tippy": "^1.3.2", "svelte-tippy": "^1.3.2",

View file

@ -23,6 +23,9 @@ importers:
markdown-it: markdown-it:
specifier: ^14.1.0 specifier: ^14.1.0
version: 14.1.0 version: 14.1.0
minidenticons:
specifier: ^4.2.1
version: 4.2.1
pretty-bytes: pretty-bytes:
specifier: ^6.1.1 specifier: ^6.1.1
version: 6.1.1 version: 6.1.1
@ -1110,6 +1113,10 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
minidenticons@4.2.1:
resolution: {integrity: sha512-oWfFivA0lOx/V/bO/YIJbthB26lV8JXYvhnv9zM2hNd3fzsHTXQ6c6bWZPcvhD3nnOB+lQk/D9lF43BXixrN8g==}
engines: {node: '>=15.14.0'}
minimatch@3.1.2: minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@ -2369,6 +2376,8 @@ snapshots:
braces: 3.0.3 braces: 3.0.3
picomatch: 2.3.1 picomatch: 2.3.1
minidenticons@4.2.1: {}
minimatch@3.1.2: minimatch@3.1.2:
dependencies: dependencies:
brace-expansion: 1.1.11 brace-expansion: 1.1.11

View file

@ -1,16 +1,22 @@
<script lang="ts"> <script lang="ts">
import { DEFAULT_AVATAR } from "$lib"; import { minidenticon } from "minidenticons";
type Props = { url: string | null; alt: string; lazyLoad?: boolean; size?: number }; type Props = { url: string | null; alt: string; lazyLoad?: boolean; size?: number; name: string };
let { url, alt, lazyLoad, size }: Props = $props(); let { url, alt, lazyLoad, size, name }: Props = $props();
let width = $derived(size || 200); let width = $derived(size || 200);
let identicon: string | null = $derived.by(() => {
if (url) return null;
return "data:image/svg+xml;utf8," + encodeURIComponent(minidenticon(name, 50, 80));
});
</script> </script>
<img <img
class="rounded-circle img-fluid" class="rounded-circle img-fluid"
style="height: {width}px; width: {width}px" style="height: {width}px; width: {width}px"
src={url || DEFAULT_AVATAR} class:identicon={!url}
src={url || identicon}
{alt} {alt}
{width} {width}
loading={lazyLoad ? "lazy" : "eager"} loading={lazyLoad ? "lazy" : "eager"}
@ -20,4 +26,12 @@
img { img {
object-fit: cover; object-fit: cover;
} }
.identicon {
@media (prefers-color-scheme: dark) {
background-color: var(--bs-secondary-border-subtle);
}
background-color: var(--bs-light-border-subtle);
}
</style> </style>

View file

@ -7,12 +7,13 @@
import ShortNoscriptWarning from "./ShortNoscriptWarning.svelte"; import ShortNoscriptWarning from "./ShortNoscriptWarning.svelte";
type Props = { type Props = {
name: string;
current: string | null; current: string | null;
alt: string; alt: string;
update: (avatar: string) => Promise<void>; update: (avatar: string) => Promise<void>;
updated: boolean; updated: boolean;
}; };
let { current, alt, update: onclick, updated }: Props = $props(); let { name, current, alt, update: onclick, updated }: Props = $props();
const MAX_AVATAR_BYTES = 1_000_000; const MAX_AVATAR_BYTES = 1_000_000;
@ -40,7 +41,7 @@
</script> </script>
<p class="text-center"> <p class="text-center">
<Avatar url={avatarExists ? avatar : current} {alt} /> <Avatar {name} url={avatarExists ? avatar : current} {alt} />
</p> </p>
<InputGroup class="mb-2"> <InputGroup class="mb-2">

View file

@ -22,6 +22,7 @@
<div class="row"> <div class="row">
<div class="col-md-4 text-center"> <div class="col-md-4 text-center">
<Avatar <Avatar
name={"name" in profile ? profile.name : profile.username}
url={profile.avatar_url} url={profile.avatar_url}
alt={$t("avatar-tooltip", { name })} alt={$t("avatar-tooltip", { name })}
lazyLoad={lazyLoadAvatar} lazyLoad={lazyLoadAvatar}

View file

@ -36,6 +36,7 @@
<div> <div>
<a href="/@{username}/{member.name}"> <a href="/@{username}/{member.name}">
<Avatar <Avatar
name={member.name}
url={member.avatar_url} url={member.avatar_url}
lazyLoad lazyLoad
alt={$t("avatar-tooltip", { name: member.display_name })} alt={$t("avatar-tooltip", { name: member.display_name })}

View file

@ -47,7 +47,8 @@
"successful-link-fedi": "Your account has successfully been linked to the following fediverse account:", "successful-link-fedi": "Your account has successfully been linked to the following fediverse account:",
"successful-link-profile-hint": "You now can close this page, or go back to your profile:", "successful-link-profile-hint": "You now can close this page, or go back to your profile:",
"successful-link-profile-link": "Go to your profile", "successful-link-profile-link": "Go to your profile",
"remote-discord-account-label": "Your Discord account" "remote-discord-account-label": "Your Discord account",
"log-in-with-fediverse-instance-placeholder": "Your instance (i.e. mastodon.social)"
}, },
"error": { "error": {
"bad-request-header": "Something was wrong with your input", "bad-request-header": "Something was wrong with your input",
@ -112,7 +113,13 @@
"create-member-name-label": "Member name", "create-member-name-label": "Member name",
"auth-remove-method": "Remove", "auth-remove-method": "Remove",
"force-log-out-warning": "Make sure you're still able to log in before using this!", "force-log-out-warning": "Make sure you're still able to log in before using this!",
"force-log-out-confirmation": "Are you sure you want to log out from all devices? If you just want to log out from this device, click the \"Log out\" button on your settings page." "force-log-out-confirmation": "Are you sure you want to log out from all devices? If you just want to log out from this device, click the \"Log out\" button on your settings page.",
"export-request-success": "Successfully requested a new export! Please note that it may take a few minutes to complete, especially if you have a lot of members.",
"export-title": "Request a copy of your data",
"export-info": "You can request a copy of your data once every 24 hours. Exports are stored for 15 days (a little over two weeks) and then deleted.",
"export-expires-at": "(expires {{expiresAt}})",
"export-download": "Download export",
"export-request-button": "Request a new export"
}, },
"yes": "Yes", "yes": "Yes",
"no": "No", "no": "No",

View file

@ -1,35 +1,62 @@
import { apiRequest } from "$api"; import { apiRequest } from "$api";
import ApiError, { ErrorCode } from "$api/error"; import ApiError, { ErrorCode } from "$api/error";
import type { CallbackResponse } from "$api/models/auth.js"; import type { AddAccountResponse, CallbackResponse } from "$api/models/auth.js";
import { setToken } from "$lib"; import { setToken } from "$lib";
import createRegisterAction from "$lib/actions/register.js"; import createRegisterAction from "$lib/actions/register.js";
import { redirect } from "@sveltejs/kit"; import log from "$lib/log";
import { isRedirect, redirect } from "@sveltejs/kit";
export const load = async ({ parent, params, url, fetch, cookies }) => { export const load = async ({ parent, params, url, fetch, cookies }) => {
const { meUser } = await parent();
if (meUser) redirect(303, `/@${meUser.username}`);
const code = url.searchParams.get("code") as string | null; const code = url.searchParams.get("code") as string | null;
const state = url.searchParams.get("state") as string | null; const state = url.searchParams.get("state") as string | null;
if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj; if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj;
const resp = await apiRequest<CallbackResponse>("POST", "/auth/fediverse/callback", { const { meUser } = await parent();
body: { code, state, instance: params.instance }, if (meUser) {
isInternal: true, try {
fetch, const resp = await apiRequest<AddAccountResponse>(
}); "POST",
"/auth/fediverse/add-account/callback",
{
isInternal: true,
body: { code, state, instance: params.instance },
fetch,
cookies,
},
);
if (resp.has_account) { return { hasAccount: true, isLinkRequest: true, newAuthMethod: resp };
setToken(cookies, resp.token!); } catch (e) {
redirect(303, `/@${resp.user!.username}`); if (e instanceof ApiError) return { isLinkRequest: true, error: e.obj };
log.error("error linking new fediverse account to user %s:", meUser.id, e);
throw e;
}
} }
return { try {
hasAccount: false, const resp = await apiRequest<CallbackResponse>("POST", "/auth/fediverse/callback", {
instance: params.instance, body: { code, state, instance: params.instance },
ticket: resp.ticket!, isInternal: true,
remoteUser: resp.remote_username!, fetch,
}; });
if (resp.has_account) {
setToken(cookies, resp.token!);
redirect(303, `/@${resp.user!.username}`);
}
return {
hasAccount: false,
isLinkRequest: false,
ticket: resp.ticket!,
remoteUser: resp.remote_username!,
};
} catch (e) {
if (isRedirect(e)) throw e;
if (e instanceof ApiError) return { isLinkRequest: false, error: e.obj };
log.error("error while requesting fediverse callback:", e);
throw e;
}
}; };
export const actions = { export const actions = {

View file

@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import type { ActionData, PageData } from "./$types"; import type { ActionData, PageData } from "./$types";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import Error from "$components/Error.svelte";
import OauthRegistrationForm from "$components/settings/OauthRegistrationForm.svelte"; import OauthRegistrationForm from "$components/settings/OauthRegistrationForm.svelte";
import NewAuthMethod from "$components/settings/NewAuthMethod.svelte";
type Props = { data: PageData; form: ActionData }; type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props(); let { data, form }: Props = $props();
@ -12,11 +14,18 @@
</svelte:head> </svelte:head>
<div class="container"> <div class="container">
<OauthRegistrationForm {#if data.error}
title={$t("auth.register-with-mastodon")} <h1>{$t("auth.register-with-mastodon")}</h1>
remoteLabel={$t("auth.remote-fediverse-account-label")} <Error error={data.error} />
remoteUser={data.remoteUser} {:else if data.isLinkRequest}
ticket={data.ticket} <NewAuthMethod method={data.newAuthMethod!} user={data.meUser!} />
error={form?.error} {:else}
/> <OauthRegistrationForm
title={$t("auth.register-with-mastodon")}
remoteLabel={$t("auth.remote-fediverse-account-label")}
remoteUser={data.remoteUser!}
ticket={data.ticket!}
error={form?.error}
/>
{/if}
</div> </div>

View file

@ -72,7 +72,11 @@
<h4 class="mt-4">{$t("auth.log-in-with-the-fediverse")}</h4> <h4 class="mt-4">{$t("auth.log-in-with-the-fediverse")}</h4>
<form method="POST" action="?/fedi" use:enhance> <form method="POST" action="?/fedi" use:enhance>
<InputGroup> <InputGroup>
<Input name="instance" type="text" placeholder="Your instance (i.e. mastodon.social)" /> <Input
name="instance"
type="text"
placeholder={$t("auth.log-in-with-fediverse-instance-placeholder")}
/>
<Button type="submit" color="secondary">{$t("auth.log-in-button")}</Button> <Button type="submit" color="secondary">{$t("auth.log-in-button")}</Button>
</InputGroup> </InputGroup>
<p> <p>

View file

@ -7,6 +7,7 @@
import Error from "$components/Error.svelte"; import Error from "$components/Error.svelte";
import { idTimestamp } from "$lib"; import { idTimestamp } from "$lib";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { enhance } from "$app/forms";
type Props = { data: PageData; form: ActionData }; type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props(); let { data, form }: Props = $props();
@ -20,7 +21,7 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-9"> <div class="col-md-9">
<h5>Change your username</h5> <h5>Change your username</h5>
<form method="POST" action="?/changeUsername"> <form method="POST" action="?/changeUsername" use:enhance>
<FormGroup class="mb-3"> <FormGroup class="mb-3">
<InputGroup class="m-1 mt-3 w-md-75"> <InputGroup class="m-1 mt-3 w-md-75">
<Input <Input
@ -55,6 +56,7 @@
<div class="col-md-3 text-center"> <div class="col-md-3 text-center">
<h5>{$t("settings.avatar")}</h5> <h5>{$t("settings.avatar")}</h5>
<Avatar <Avatar
name={data.user.username}
url={data.user.avatar_url} url={data.user.avatar_url}
alt={$t("avatar-tooltip", { name: "@" + data.user.username })} alt={$t("avatar-tooltip", { name: "@" + data.user.username })}
/> />

View file

@ -0,0 +1,37 @@
import { apiRequest } from "$api";
import { redirect } from "@sveltejs/kit";
export const actions = {
add: async ({ request, fetch, cookies }) => {
const body = await request.formData();
const instance = body.get("instance") as string;
const { url } = await apiRequest<{ url: string }>(
"GET",
`/auth/fediverse/add-account?instance=${encodeURIComponent(instance)}`,
{
isInternal: true,
fetch,
cookies,
},
);
redirect(303, url);
},
forceRefresh: async ({ request, fetch, cookies }) => {
const body = await request.formData();
const instance = body.get("instance") as string;
const { url } = await apiRequest<{ url: string }>(
"GET",
`/auth/fediverse/add-account?instance=${encodeURIComponent(instance)}&forceRefresh=true`,
{
isInternal: true,
fetch,
cookies,
},
);
redirect(303, url);
},
};

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { t } from "$lib/i18n";
import { Button, Input, InputGroup } from "@sveltestrap/sveltestrap";
</script>
<h3>Link a new Fediverse account</h3>
<form method="POST" action="?/add">
<InputGroup>
<Input
name="instance"
type="text"
placeholder={$t("auth.log-in-with-fediverse-instance-placeholder")}
/>
<Button type="submit" color="secondary">{$t("auth.log-in-button")}</Button>
</InputGroup>
<p>
{$t("auth.log-in-with-fediverse-error-blurb")}
<Button formaction="?/forceRefresh" type="submit" color="link">
{$t("auth.log-in-with-fediverse-force-refresh-button")}
</Button>
</p>
</form>

View file

@ -0,0 +1,35 @@
import { apiRequest, fastRequest } from "$api";
import ApiError from "$api/error.js";
import log from "$lib/log.js";
import { DateTime, Duration } from "luxon";
type Export = { url: string | null; expires_at: string | null };
export const load = async ({ fetch, cookies }) => {
const resp = await apiRequest<Export>("GET", "/data-exports", {
fetch,
cookies,
isInternal: true,
});
let canExport = true;
if (resp.expires_at) {
const created = DateTime.fromISO(resp.expires_at).minus(Duration.fromObject({ days: 15 }));
canExport = DateTime.now().diff(created, "seconds").seconds >= 86400;
}
return { url: resp.url, expiresAt: resp.expires_at, canExport };
};
export const actions = {
default: async ({ fetch, cookies }) => {
try {
fastRequest("POST", "/data-exports", { fetch, cookies, isInternal: true });
return { ok: true, error: null };
} catch (e) {
if (e instanceof ApiError) return { ok: false, error: e.obj };
log.error("Error requesting data export:", e);
throw e;
}
},
};

View file

@ -0,0 +1,49 @@
<script lang="ts">
import { DateTime } from "luxon";
import type { ActionData, PageData } from "./$types";
import ErrorAlert from "$components/ErrorAlert.svelte";
import { Icon } from "@sveltestrap/sveltestrap";
import { t } from "$lib/i18n";
import { enhance } from "$app/forms";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
let expiresAt = $derived.by(() => {
if (!data.expiresAt) return null;
return DateTime.fromISO(data.expiresAt);
});
</script>
<div class="mx-auto w-lg-75">
<h3>{$t("settings.export-title")}</h3>
{#if form?.ok}
<p class="text-success-emphasis">
<Icon name="check-circle-fill" />
{$t("settings.export-request-success")}
</p>
{:else if form?.error}
<ErrorAlert error={form.error} />
{/if}
<p>
{$t("settings.export-info")}
</p>
<form method="POST" use:enhance>
<div class="btn-group">
<button type="submit" class="btn btn-primary" disabled={!data.canExport}>
{$t("settings.export-request-button")}
</button>
{#if data.url}
<a href={data.url} target="_blank" class="btn btn-success">
{$t("settings.export-download")}
{#if expiresAt}
{$t("settings.export-expires-at", { expiresAt: expiresAt.toRelative() })}
{/if}
</a>
{/if}
</div>
</form>
</div>

View file

@ -30,6 +30,7 @@
{#each data.members as member (member.id)} {#each data.members as member (member.id)}
<ListGroupItem tag="a" href="/settings/members/{member.id}" data-sveltekit-preload-data="tap"> <ListGroupItem tag="a" href="/settings/members/{member.id}" data-sveltekit-preload-data="tap">
<Avatar <Avatar
name={member.name}
url={member.avatar_url} url={member.avatar_url}
alt={$t("avatar-tooltip", { name: member.display_name })} alt={$t("avatar-tooltip", { name: member.display_name })}
size={20} size={20}

View file

@ -14,6 +14,7 @@
import SidEditor from "$components/editor/SidEditor.svelte"; import SidEditor from "$components/editor/SidEditor.svelte";
import BioEditor from "$components/editor/BioEditor.svelte"; import BioEditor from "$components/editor/BioEditor.svelte";
import { PUBLIC_BASE_URL } from "$env/static/public"; import { PUBLIC_BASE_URL } from "$env/static/public";
import { enhance } from "$app/forms";
type Props = { data: PageData; form: ActionData }; type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props(); let { data, form }: Props = $props();
@ -74,6 +75,7 @@
<div class="col-md"> <div class="col-md">
<h4>{$t("settings.avatar")}</h4> <h4>{$t("settings.avatar")}</h4>
<AvatarEditor <AvatarEditor
name={data.member.name}
current={data.member.avatar_url} current={data.member.avatar_url}
alt={$t("avatar-tooltip", { name: data.member.name })} alt={$t("avatar-tooltip", { name: data.member.name })}
update={updateAvatar} update={updateAvatar}
@ -82,7 +84,7 @@
</div> </div>
<div class="col-md"> <div class="col-md">
<h4>{$t("edit-profile.member-name")}</h4> <h4>{$t("edit-profile.member-name")}</h4>
<form method="POST" action="?/changeName" class="mb-3"> <form method="POST" action="?/changeName" class="mb-3" use:enhance>
<InputGroup> <InputGroup>
<input <input
name="name" name="name"
@ -98,7 +100,7 @@
</form> </form>
<h4>{$t("edit-profile.display-name")}</h4> <h4>{$t("edit-profile.display-name")}</h4>
<form class="mb-3" method="POST" action="?/changeDisplayName"> <form class="mb-3" method="POST" action="?/changeDisplayName" use:enhance>
<InputGroup> <InputGroup>
<input <input
class="form-control" class="form-control"
@ -116,7 +118,7 @@
</div> </div>
<div class="row mb-3"> <div class="row mb-3">
<h4>{$t("edit-profile.profile-options-header")}</h4> <h4>{$t("edit-profile.profile-options-header")}</h4>
<form method="POST" action="?/options"> <form method="POST" action="?/options" use:enhance>
<div class="form-check"> <div class="form-check">
<input <input
class="form-check-input" class="form-check-input"
@ -145,7 +147,7 @@
</div> </div>
<div class="row mb-3"> <div class="row mb-3">
<h4>{$t("edit-profile.bio-tab")}</h4> <h4>{$t("edit-profile.bio-tab")}</h4>
<form method="POST" action="?/bio"> <form method="POST" action="?/bio" use:enhance>
<BioEditor bind:value={bio} maxLength={data.meta.limits.bio_length} /> <BioEditor bind:value={bio} maxLength={data.meta.limits.bio_length} />
</form> </form>
</div> </div>

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { enhance } from "$app/forms";
import ErrorAlert from "$components/ErrorAlert.svelte"; import ErrorAlert from "$components/ErrorAlert.svelte";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import type { ActionData } from "./$types"; import type { ActionData } from "./$types";
@ -13,7 +14,7 @@
<ErrorAlert error={form.error} /> <ErrorAlert error={form.error} />
{/if} {/if}
<form method="POST"> <form method="POST" use:enhance>
<div class="my-3"> <div class="my-3">
<label class="form-label" for="name">{$t("settings.create-member-name-label")}</label> <label class="form-label" for="name">{$t("settings.create-member-name-label")}</label>
<input class="form-control" type="text" id="name" name="name" required autocomplete="off" /> <input class="form-control" type="text" id="name" name="name" required autocomplete="off" />

View file

@ -11,6 +11,7 @@
import { DateTime, FixedOffsetZone } from "luxon"; import { DateTime, FixedOffsetZone } from "luxon";
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte"; import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
import SidEditor from "$components/editor/SidEditor.svelte"; import SidEditor from "$components/editor/SidEditor.svelte";
import { enhance } from "$app/forms";
type Props = { data: PageData; form: ActionData }; type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props(); let { data, form }: Props = $props();
@ -91,6 +92,7 @@
<div class="col-md"> <div class="col-md">
<h4>{$t("settings.avatar")}</h4> <h4>{$t("settings.avatar")}</h4>
<AvatarEditor <AvatarEditor
name={data.user.username}
current={data.user.avatar_url} current={data.user.avatar_url}
alt={$t("avatar-tooltip", { name: "@" + data.user.username })} alt={$t("avatar-tooltip", { name: "@" + data.user.username })}
update={updateAvatar} update={updateAvatar}
@ -127,7 +129,7 @@
<div class="mt-3"> <div class="mt-3">
<h4>{$t("edit-profile.profile-options-header")}</h4> <h4>{$t("edit-profile.profile-options-header")}</h4>
<form method="POST" action="?/options"> <form method="POST" action="?/options" use:enhance>
<div class="mb-3"> <div class="mb-3">
<label class="form-label" for="member-title">{$t("edit-profile.member-header-label")}</label> <label class="form-label" for="member-title">{$t("edit-profile.member-header-label")}</label>
<input <input

View file

@ -3,6 +3,7 @@
import type { ActionData, PageData } from "./$types"; import type { ActionData, PageData } from "./$types";
import BioEditor from "$components/editor/BioEditor.svelte"; import BioEditor from "$components/editor/BioEditor.svelte";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import { enhance } from "$app/forms";
type Props = { data: PageData; form: ActionData }; type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props(); let { data, form }: Props = $props();
@ -12,6 +13,6 @@
<h4>{$t("edit-profile.bio-tab")}</h4> <h4>{$t("edit-profile.bio-tab")}</h4>
<FormStatusMarker {form} /> <FormStatusMarker {form} />
<form method="POST"> <form method="POST" use:enhance>
<BioEditor bind:value={bio} maxLength={data.meta.limits.bio_length} /> <BioEditor bind:value={bio} maxLength={data.meta.limits.bio_length} />
</form> </form>

View file

@ -1,25 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Foxnouns.Backend\Foxnouns.Backend.csproj" /> <ProjectReference Include="..\..\Foxnouns.Backend\Foxnouns.Backend.csproj"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.1.0" /> <PackageReference Include="Humanizer.Core" Version="2.14.1"/>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" /> <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.1.0"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7"> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7"/>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
<PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> <PrivateAssets>all</PrivateAssets>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" /> </PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4"/>
</ItemGroup> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4"/>
</ItemGroup>
</Project> </Project>