Compare commits
2 commits
d982342ab8
...
c4cb08cdc1
Author | SHA1 | Date | |
---|---|---|---|
c4cb08cdc1 | |||
5a22807410 |
18 changed files with 487 additions and 121 deletions
|
@ -50,17 +50,6 @@ public class AuthController(
|
||||||
Instant ExpiresAt
|
Instant ExpiresAt
|
||||||
);
|
);
|
||||||
|
|
||||||
public record CallbackResponse(
|
|
||||||
bool HasAccount,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Ticket,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
|
||||||
string? RemoteUsername,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
|
||||||
UserRendererService.UserResponse? User,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Token,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? ExpiresAt
|
|
||||||
);
|
|
||||||
|
|
||||||
public record OauthRegisterRequest(string Ticket, string Username);
|
public record OauthRegisterRequest(string Ticket, string Username);
|
||||||
|
|
||||||
public record CallbackRequest(string Code, string State);
|
public record CallbackRequest(string Code, string State);
|
||||||
|
@ -77,3 +66,13 @@ public class AuthController(
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public record CallbackResponse(
|
||||||
|
bool HasAccount,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Ticket,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? RemoteUsername,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
UserRendererService.UserResponse? User,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Token,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? ExpiresAt
|
||||||
|
);
|
||||||
|
|
|
@ -2,6 +2,7 @@ using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
|
using Foxnouns.Backend.Services.Auth;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using JetBrains.Annotations;
|
using JetBrains.Annotations;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
@ -14,32 +15,25 @@ namespace Foxnouns.Backend.Controllers.Authentication;
|
||||||
public class DiscordAuthController(
|
public class DiscordAuthController(
|
||||||
[UsedImplicitly] Config config,
|
[UsedImplicitly] Config config,
|
||||||
ILogger logger,
|
ILogger logger,
|
||||||
IClock clock,
|
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
KeyCacheService keyCacheService,
|
KeyCacheService keyCacheService,
|
||||||
AuthService authService,
|
AuthService authService,
|
||||||
RemoteAuthService remoteAuthService,
|
RemoteAuthService remoteAuthService
|
||||||
UserRendererService userRenderer
|
|
||||||
) : ApiControllerBase
|
) : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ILogger _logger = logger.ForContext<DiscordAuthController>();
|
private readonly ILogger _logger = logger.ForContext<DiscordAuthController>();
|
||||||
|
|
||||||
[HttpPost("callback")]
|
[HttpPost("callback")]
|
||||||
// TODO: duplicating attribute doesn't work, find another way to mark both as possible response
|
[ProducesResponseType<CallbackResponse>(StatusCodes.Status200OK)]
|
||||||
// leaving it here for documentation purposes
|
public async Task<IActionResult> CallbackAsync([FromBody] AuthController.CallbackRequest req)
|
||||||
[ProducesResponseType<AuthController.CallbackResponse>(StatusCodes.Status200OK)]
|
|
||||||
public async Task<IActionResult> CallbackAsync(
|
|
||||||
[FromBody] AuthController.CallbackRequest req,
|
|
||||||
CancellationToken ct = default
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
CheckRequirements();
|
CheckRequirements();
|
||||||
await keyCacheService.ValidateAuthStateAsync(req.State, ct);
|
await keyCacheService.ValidateAuthStateAsync(req.State);
|
||||||
|
|
||||||
var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code, req.State, ct);
|
var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code);
|
||||||
var user = await authService.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id, ct: ct);
|
var user = await authService.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id);
|
||||||
if (user != null)
|
if (user != null)
|
||||||
return Ok(await GenerateUserTokenAsync(user, ct));
|
return Ok(await authService.GenerateUserTokenAsync(user));
|
||||||
|
|
||||||
_logger.Debug(
|
_logger.Debug(
|
||||||
"Discord user {Username} ({Id}) authenticated with no local account",
|
"Discord user {Username} ({Id}) authenticated with no local account",
|
||||||
|
@ -51,12 +45,11 @@ public class DiscordAuthController(
|
||||||
await keyCacheService.SetKeyAsync(
|
await keyCacheService.SetKeyAsync(
|
||||||
$"discord:{ticket}",
|
$"discord:{ticket}",
|
||||||
remoteUser,
|
remoteUser,
|
||||||
Duration.FromMinutes(20),
|
Duration.FromMinutes(20)
|
||||||
ct
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return Ok(
|
return Ok(
|
||||||
new AuthController.CallbackResponse(
|
new CallbackResponse(
|
||||||
HasAccount: false,
|
HasAccount: false,
|
||||||
Ticket: ticket,
|
Ticket: ticket,
|
||||||
RemoteUsername: remoteUser.Username,
|
RemoteUsername: remoteUser.Username,
|
||||||
|
@ -98,42 +91,7 @@ public class DiscordAuthController(
|
||||||
remoteUser.Username
|
remoteUser.Username
|
||||||
);
|
);
|
||||||
|
|
||||||
return Ok(await GenerateUserTokenAsync(user));
|
return Ok(await authService.GenerateUserTokenAsync(user));
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<AuthController.CallbackResponse> GenerateUserTokenAsync(
|
|
||||||
User user,
|
|
||||||
CancellationToken ct = default
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var frontendApp = await db.GetFrontendApplicationAsync(ct);
|
|
||||||
_logger.Debug("Logging user {Id} in with Discord", user.Id);
|
|
||||||
|
|
||||||
var (tokenStr, token) = authService.GenerateToken(
|
|
||||||
user,
|
|
||||||
frontendApp,
|
|
||||||
["*"],
|
|
||||||
clock.GetCurrentInstant() + Duration.FromDays(365)
|
|
||||||
);
|
|
||||||
db.Add(token);
|
|
||||||
|
|
||||||
_logger.Debug("Generated token {TokenId} for {UserId}", user.Id, token.Id);
|
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
return new AuthController.CallbackResponse(
|
|
||||||
HasAccount: true,
|
|
||||||
Ticket: null,
|
|
||||||
RemoteUsername: null,
|
|
||||||
User: await userRenderer.RenderUserAsync(
|
|
||||||
user,
|
|
||||||
selfUser: user,
|
|
||||||
renderMembers: false,
|
|
||||||
ct: ct
|
|
||||||
),
|
|
||||||
Token: tokenStr,
|
|
||||||
ExpiresAt: token.ExpiresAt
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CheckRequirements()
|
private void CheckRequirements()
|
||||||
|
|
|
@ -3,6 +3,7 @@ using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
|
using Foxnouns.Backend.Services.Auth;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using JetBrains.Annotations;
|
using JetBrains.Annotations;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
@ -84,7 +85,7 @@ public class EmailAuthController(
|
||||||
await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20));
|
await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20));
|
||||||
|
|
||||||
return Ok(
|
return Ok(
|
||||||
new AuthController.CallbackResponse(
|
new CallbackResponse(
|
||||||
HasAccount: false,
|
HasAccount: false,
|
||||||
Ticket: ticket,
|
Ticket: ticket,
|
||||||
RemoteUsername: state.Email,
|
RemoteUsername: state.Email,
|
||||||
|
|
|
@ -1,11 +1,26 @@
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
|
using Foxnouns.Backend.Services.Auth;
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
|
using FediverseAuthService = Foxnouns.Backend.Services.Auth.FediverseAuthService;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Controllers.Authentication;
|
namespace Foxnouns.Backend.Controllers.Authentication;
|
||||||
|
|
||||||
[Route("/api/internal/auth/fediverse")]
|
[Route("/api/internal/auth/fediverse")]
|
||||||
public class FediverseAuthController(FediverseAuthService fediverseAuthService) : ApiControllerBase
|
public class FediverseAuthController(
|
||||||
|
ILogger logger,
|
||||||
|
DatabaseContext db,
|
||||||
|
FediverseAuthService fediverseAuthService,
|
||||||
|
AuthService authService,
|
||||||
|
KeyCacheService keyCacheService
|
||||||
|
) : ApiControllerBase
|
||||||
{
|
{
|
||||||
|
private readonly ILogger _logger = logger.ForContext<FediverseAuthController>();
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType<FediverseUrlResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<FediverseUrlResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> GetFediverseUrlAsync([FromQuery] string instance)
|
public async Task<IActionResult> GetFediverseUrlAsync([FromQuery] string instance)
|
||||||
|
@ -14,12 +29,88 @@ public class FediverseAuthController(FediverseAuthService fediverseAuthService)
|
||||||
return Ok(new FediverseUrlResponse(url));
|
return Ok(new FediverseUrlResponse(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("callback")]
|
||||||
|
[ProducesResponseType<CallbackResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> FediverseCallbackAsync([FromBody] CallbackRequest req)
|
public async Task<IActionResult> FediverseCallbackAsync([FromBody] CallbackRequest req)
|
||||||
{
|
{
|
||||||
throw new NotImplementedException();
|
var app = await fediverseAuthService.GetApplicationAsync(req.Instance);
|
||||||
|
var remoteUser = await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code);
|
||||||
|
|
||||||
|
var user = await authService.AuthenticateUserAsync(
|
||||||
|
AuthType.Fediverse,
|
||||||
|
remoteUser.Id,
|
||||||
|
instance: app
|
||||||
|
);
|
||||||
|
if (user != null)
|
||||||
|
return Ok(await authService.GenerateUserTokenAsync(user));
|
||||||
|
|
||||||
|
var ticket = AuthUtils.RandomToken();
|
||||||
|
await keyCacheService.SetKeyAsync(
|
||||||
|
$"fediverse:{ticket}",
|
||||||
|
new FediverseTicketData(app.Id, remoteUser),
|
||||||
|
Duration.FromMinutes(20)
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(
|
||||||
|
new CallbackResponse(
|
||||||
|
HasAccount: false,
|
||||||
|
Ticket: ticket,
|
||||||
|
RemoteUsername: $"@{remoteUser.Username}@{app.Domain}",
|
||||||
|
User: null,
|
||||||
|
Token: null,
|
||||||
|
ExpiresAt: null
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("register")]
|
||||||
|
[ProducesResponseType<AuthController.AuthResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> RegisterAsync(
|
||||||
|
[FromBody] AuthController.OauthRegisterRequest req
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var ticketData = await keyCacheService.GetKeyAsync<FediverseTicketData>(
|
||||||
|
$"fediverse:{req.Ticket}"
|
||||||
|
);
|
||||||
|
if (ticketData == null)
|
||||||
|
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
|
||||||
|
|
||||||
|
var app = await db.FediverseApplications.FindAsync(ticketData.ApplicationId);
|
||||||
|
if (
|
||||||
|
await db.AuthMethods.AnyAsync(a =>
|
||||||
|
a.AuthType == AuthType.Fediverse
|
||||||
|
&& a.RemoteId == ticketData.User.Id
|
||||||
|
&& a.FediverseApplicationId == app.Id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_logger.Error(
|
||||||
|
"Fediverse user {Id}/{ApplicationId} ({Username} on {Domain}) has valid ticket but is already linked to an existing account",
|
||||||
|
ticketData.User.Id,
|
||||||
|
ticketData.ApplicationId,
|
||||||
|
ticketData.User.Username,
|
||||||
|
app.Domain
|
||||||
|
);
|
||||||
|
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await authService.CreateUserWithRemoteAuthAsync(
|
||||||
|
req.Username,
|
||||||
|
AuthType.Fediverse,
|
||||||
|
ticketData.User.Id,
|
||||||
|
ticketData.User.Username,
|
||||||
|
instance: app
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(await authService.GenerateUserTokenAsync(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
public record CallbackRequest(string Instance, string Code);
|
public record CallbackRequest(string Instance, string Code);
|
||||||
|
|
||||||
private record FediverseUrlResponse(string Url);
|
private record FediverseUrlResponse(string Url);
|
||||||
|
|
||||||
|
private record FediverseTicketData(
|
||||||
|
Snowflake ApplicationId,
|
||||||
|
FediverseAuthService.FediverseUser User
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,12 +4,14 @@ using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Jobs;
|
using Foxnouns.Backend.Jobs;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
|
using Foxnouns.Backend.Services.Auth;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Minio;
|
using Minio;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using Prometheus;
|
using Prometheus;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Serilog.Events;
|
using Serilog.Events;
|
||||||
|
using FediverseAuthService = Foxnouns.Backend.Services.Auth.FediverseAuthService;
|
||||||
using IClock = NodaTime.IClock;
|
using IClock = NodaTime.IClock;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Extensions;
|
namespace Foxnouns.Backend.Extensions;
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using Foxnouns.Backend.Controllers.Authentication;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
|
@ -6,10 +7,17 @@ using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Services;
|
namespace Foxnouns.Backend.Services.Auth;
|
||||||
|
|
||||||
public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator snowflakeGenerator)
|
public class AuthService(
|
||||||
|
IClock clock,
|
||||||
|
ILogger logger,
|
||||||
|
DatabaseContext db,
|
||||||
|
ISnowflakeGenerator snowflakeGenerator,
|
||||||
|
UserRendererService userRenderer
|
||||||
|
)
|
||||||
{
|
{
|
||||||
|
private readonly ILogger _logger = logger.ForContext<AuthService>();
|
||||||
private readonly PasswordHasher<User> _passwordHasher = new();
|
private readonly PasswordHasher<User> _passwordHasher = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -256,6 +264,45 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a token for the given user and adds it to the database, returning a fully formed auth response for the user.
|
||||||
|
/// This method is always called at the end of an endpoint method, so the resulting token
|
||||||
|
/// (and user, if this is a registration request) is also saved to the database.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<CallbackResponse> GenerateUserTokenAsync(
|
||||||
|
User user,
|
||||||
|
CancellationToken ct = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var frontendApp = await db.GetFrontendApplicationAsync(ct);
|
||||||
|
|
||||||
|
var (tokenStr, token) = GenerateToken(
|
||||||
|
user,
|
||||||
|
frontendApp,
|
||||||
|
["*"],
|
||||||
|
clock.GetCurrentInstant() + Duration.FromDays(365)
|
||||||
|
);
|
||||||
|
db.Add(token);
|
||||||
|
|
||||||
|
_logger.Debug("Generated token {TokenId} for {UserId}", user.Id, token.Id);
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return new CallbackResponse(
|
||||||
|
HasAccount: true,
|
||||||
|
Ticket: null,
|
||||||
|
RemoteUsername: null,
|
||||||
|
User: await userRenderer.RenderUserAsync(
|
||||||
|
user,
|
||||||
|
selfUser: user,
|
||||||
|
renderMembers: false,
|
||||||
|
ct: ct
|
||||||
|
),
|
||||||
|
Token: tokenStr,
|
||||||
|
ExpiresAt: token.ExpiresAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private static (string, byte[]) GenerateToken()
|
private static (string, byte[]) GenerateToken()
|
||||||
{
|
{
|
||||||
var token = AuthUtils.RandomToken();
|
var token = AuthUtils.RandomToken();
|
|
@ -5,12 +5,12 @@ using Foxnouns.Backend.Database.Models;
|
||||||
using Duration = NodaTime.Duration;
|
using Duration = NodaTime.Duration;
|
||||||
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
|
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Services;
|
namespace Foxnouns.Backend.Services.Auth;
|
||||||
|
|
||||||
public partial class FediverseAuthService
|
public partial class FediverseAuthService
|
||||||
{
|
{
|
||||||
private string MastodonRedirectUri(string instance) =>
|
private string MastodonRedirectUri(string instance) =>
|
||||||
$"{_config.BaseUrl}/auth/login/mastodon/{instance}";
|
$"{_config.BaseUrl}/auth/callback/mastodon/{instance}";
|
||||||
|
|
||||||
private async Task<FediverseApplication> CreateMastodonApplicationAsync(
|
private async Task<FediverseApplication> CreateMastodonApplicationAsync(
|
||||||
string instance,
|
string instance,
|
|
@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
|
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Services;
|
namespace Foxnouns.Backend.Services.Auth;
|
||||||
|
|
||||||
public partial class FediverseAuthService
|
public partial class FediverseAuthService
|
||||||
{
|
{
|
||||||
|
@ -43,12 +43,6 @@ public partial class FediverseAuthService
|
||||||
return await GenerateAuthUrlAsync(app);
|
return await GenerateAuthUrlAsync(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<FediverseUser> GetRemoteFediverseUserAsync(string instance, string code)
|
|
||||||
{
|
|
||||||
var app = await GetApplicationAsync(instance);
|
|
||||||
return await GetRemoteUserAsync(app, code);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
||||||
// and having both mastodon and misskey use "username" in the self user response
|
// and having both mastodon and misskey use "username" in the self user response
|
||||||
public record FediverseUser(
|
public record FediverseUser(
|
||||||
|
@ -56,7 +50,7 @@ public partial class FediverseAuthService
|
||||||
[property: J("username")] string Username
|
[property: J("username")] string Username
|
||||||
);
|
);
|
||||||
|
|
||||||
private async Task<FediverseApplication> GetApplicationAsync(string instance)
|
public async Task<FediverseApplication> GetApplicationAsync(string instance)
|
||||||
{
|
{
|
||||||
var app = await _db.FediverseApplications.FirstOrDefaultAsync(a => a.Domain == instance);
|
var app = await _db.FediverseApplications.FirstOrDefaultAsync(a => a.Domain == instance);
|
||||||
if (app != null)
|
if (app != null)
|
||||||
|
@ -110,7 +104,10 @@ public partial class FediverseAuthService
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
|
_ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
|
||||||
};
|
};
|
||||||
|
|
||||||
private async Task<FediverseUser> GetRemoteUserAsync(FediverseApplication app, string code) =>
|
public async Task<FediverseUser> GetRemoteFediverseUserAsync(
|
||||||
|
FediverseApplication app,
|
||||||
|
string code
|
||||||
|
) =>
|
||||||
app.InstanceType switch
|
app.InstanceType switch
|
||||||
{
|
{
|
||||||
FediverseInstanceType.MastodonApi => await GetMastodonUserAsync(app, code),
|
FediverseInstanceType.MastodonApi => await GetMastodonUserAsync(app, code),
|
|
@ -13,7 +13,6 @@ public class RemoteAuthService(Config config, ILogger logger)
|
||||||
|
|
||||||
public async Task<RemoteUser> RequestDiscordTokenAsync(
|
public async Task<RemoteUser> RequestDiscordTokenAsync(
|
||||||
string code,
|
string code,
|
||||||
string state,
|
|
||||||
CancellationToken ct = default
|
CancellationToken ct = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
|
|
@ -97,7 +97,7 @@ public class UserRendererService(
|
||||||
? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp"
|
? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp"
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
public string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp";
|
private string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp";
|
||||||
|
|
||||||
public record UserResponse(
|
public record UserResponse(
|
||||||
Snowflake Id,
|
Snowflake Id,
|
||||||
|
|
36
Foxnouns.Frontend/app/components/RegisterError.tsx
Normal file
36
Foxnouns.Frontend/app/components/RegisterError.tsx
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import { ApiError, firstErrorFor } from "~/lib/api/error";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
import { Alert } from "react-bootstrap";
|
||||||
|
import { Link } from "@remix-run/react";
|
||||||
|
import ErrorAlert from "~/components/ErrorAlert";
|
||||||
|
|
||||||
|
export default function RegisterError({ error }: { error: ApiError }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// TODO: maybe turn these messages into their own error codes?
|
||||||
|
const ticketMessage = firstErrorFor(error, "ticket")?.message;
|
||||||
|
const usernameMessage = firstErrorFor(error, "username")?.message;
|
||||||
|
|
||||||
|
if (ticketMessage === "Invalid ticket") {
|
||||||
|
return (
|
||||||
|
<Alert variant="danger">
|
||||||
|
<Alert.Heading as="h4">{t("error.heading")}</Alert.Heading>
|
||||||
|
<Trans t={t} i18nKey={"log-in.callback.invalid-ticket"}>
|
||||||
|
Invalid ticket (it might have been too long since you logged in), please{" "}
|
||||||
|
<Link to="/auth/log-in">try again</Link>.
|
||||||
|
</Trans>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usernameMessage === "Username is already taken") {
|
||||||
|
return (
|
||||||
|
<Alert variant="danger">
|
||||||
|
<Alert.Heading as="h4">{t("log-in.callback.invalid-username")}</Alert.Heading>
|
||||||
|
{t("log-in.callback.username-taken")}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ErrorAlert error={error} />;
|
||||||
|
}
|
|
@ -30,7 +30,7 @@ export async function baseRequest(
|
||||||
headers: {
|
headers: {
|
||||||
...params.headers,
|
...params.headers,
|
||||||
...(params.token ? { Authorization: params.token } : {}),
|
...(params.token ? { Authorization: params.token } : {}),
|
||||||
"Content-Type": "application/json",
|
...(params.body ? { "Content-Type": "application/json" } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -22,6 +22,7 @@ import ErrorAlert from "~/components/ErrorAlert";
|
||||||
import i18n from "~/i18next.server";
|
import i18n from "~/i18next.server";
|
||||||
import { tokenCookieName } from "~/lib/utils";
|
import { tokenCookieName } from "~/lib/utils";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import RegisterError from "~/components/RegisterError";
|
||||||
|
|
||||||
export const meta: MetaFunction<typeof loader> = ({ data }) => {
|
export const meta: MetaFunction<typeof loader> = ({ data }) => {
|
||||||
return [{ title: `${data?.meta.title || "Log in"} • pronouns.cc` }];
|
return [{ title: `${data?.meta.title || "Log in"} • pronouns.cc` }];
|
||||||
|
@ -163,34 +164,3 @@ export default function DiscordCallbackPage() {
|
||||||
</RemixForm>
|
</RemixForm>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RegisterError({ error }: { error: ApiError }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// TODO: maybe turn these messages into their own error codes?
|
|
||||||
const ticketMessage = firstErrorFor(error, "ticket")?.message;
|
|
||||||
const usernameMessage = firstErrorFor(error, "username")?.message;
|
|
||||||
|
|
||||||
if (ticketMessage === "Invalid ticket") {
|
|
||||||
return (
|
|
||||||
<Alert variant="danger">
|
|
||||||
<Alert.Heading as="h4">{t("error.heading")}</Alert.Heading>
|
|
||||||
<Trans t={t} i18nKey={"log-in.callback.invalid-ticket"}>
|
|
||||||
Invalid ticket (it might have been too long since you logged in with Discord), please{" "}
|
|
||||||
<Link to="/auth/log-in">try again</Link>.
|
|
||||||
</Trans>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (usernameMessage === "Username is already taken") {
|
|
||||||
return (
|
|
||||||
<Alert variant="danger">
|
|
||||||
<Alert.Heading as="h4">{t("log-in.callback.invalid-username")}</Alert.Heading>
|
|
||||||
{t("log-in.callback.username-taken")}
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <ErrorAlert error={error} />;
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,163 @@
|
||||||
|
import {
|
||||||
|
ActionFunctionArgs,
|
||||||
|
json,
|
||||||
|
LoaderFunctionArgs,
|
||||||
|
MetaFunction,
|
||||||
|
redirect,
|
||||||
|
} from "@remix-run/node";
|
||||||
|
import i18n from "~/i18next.server";
|
||||||
|
import { type ApiError, ErrorCode } from "~/lib/api/error";
|
||||||
|
import serverRequest, { writeCookie } from "~/lib/request.server";
|
||||||
|
import { AuthResponse, CallbackResponse } from "~/lib/api/auth";
|
||||||
|
import { tokenCookieName } from "~/lib/utils";
|
||||||
|
import {
|
||||||
|
Link,
|
||||||
|
ShouldRevalidateFunction,
|
||||||
|
useActionData,
|
||||||
|
useLoaderData,
|
||||||
|
useNavigate,
|
||||||
|
} from "@remix-run/react";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Form as RemixForm } from "@remix-run/react/dist/components";
|
||||||
|
import { Button, Form } from "react-bootstrap";
|
||||||
|
import RegisterError from "~/components/RegisterError";
|
||||||
|
|
||||||
|
export const meta: MetaFunction<typeof loader> = ({ data }) => {
|
||||||
|
return [{ title: `${data?.meta.title || "Log in"} • pronouns.cc` }];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const shouldRevalidate: ShouldRevalidateFunction = ({ actionResult }) => {
|
||||||
|
return !actionResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||||
|
const t = await i18n.getFixedT(request);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
const code = url.searchParams.get("code");
|
||||||
|
if (!code) throw { status: 400, code: ErrorCode.BadRequest, message: "Missing code" } as ApiError;
|
||||||
|
|
||||||
|
const resp = await serverRequest<CallbackResponse>("POST", "/auth/fediverse/callback", {
|
||||||
|
body: { code, instance: params.instance! },
|
||||||
|
isInternal: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.has_account) {
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
meta: { title: t("log-in.callback.title.fediverse-success") },
|
||||||
|
hasAccount: true,
|
||||||
|
user: resp.user!,
|
||||||
|
ticket: null,
|
||||||
|
remoteUser: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Set-Cookie": writeCookie(tokenCookieName, resp.token!),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
meta: { title: t("log-in.callback.title.fediverse-register") },
|
||||||
|
hasAccount: false,
|
||||||
|
user: null,
|
||||||
|
instance: params.instance!,
|
||||||
|
ticket: resp.ticket!,
|
||||||
|
remoteUser: resp.remote_username!,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const username = data.get("username") as string | null;
|
||||||
|
const ticket = data.get("ticket") as string | null;
|
||||||
|
|
||||||
|
if (!username || !ticket)
|
||||||
|
return json({
|
||||||
|
error: {
|
||||||
|
status: 403,
|
||||||
|
code: ErrorCode.BadRequest,
|
||||||
|
message: "Invalid username or ticket",
|
||||||
|
} as ApiError,
|
||||||
|
user: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await serverRequest<AuthResponse>("POST", "/auth/fediverse/register", {
|
||||||
|
body: { username, ticket },
|
||||||
|
isInternal: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect("/auth/welcome", {
|
||||||
|
headers: {
|
||||||
|
"Set-Cookie": writeCookie(tokenCookieName, resp.token),
|
||||||
|
},
|
||||||
|
status: 303,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
JSON.stringify(e);
|
||||||
|
|
||||||
|
return json({ error: e as ApiError });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FediverseCallbackPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const data = useLoaderData<typeof loader>();
|
||||||
|
const actionData = useActionData<typeof action>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (data.hasAccount) {
|
||||||
|
navigate(`/@${data.user!.username}`);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (data.hasAccount) {
|
||||||
|
const username = data.user!.username;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>{t("log-in.callback.success")}</h1>
|
||||||
|
<p>
|
||||||
|
<Trans
|
||||||
|
t={t}
|
||||||
|
i18nKey={"log-in.callback.success-link"}
|
||||||
|
values={{ username: data.user!.username }}
|
||||||
|
>
|
||||||
|
{/* @ts-expect-error react-i18next handles interpolation here */}
|
||||||
|
Welcome back, <Link to={`/@${data.user!.username}`}>@{{ username }}</Link>!
|
||||||
|
</Trans>
|
||||||
|
<br />
|
||||||
|
{t("log-in.callback.redirect-hint")}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RemixForm method="POST">
|
||||||
|
<Form as="div">
|
||||||
|
{actionData?.error && <RegisterError error={actionData.error} />}
|
||||||
|
<Form.Group className="mb-3" controlId="remote-username">
|
||||||
|
<Form.Label>{t("log-in.callback.remote-username.fediverse")}</Form.Label>
|
||||||
|
<Form.Control type="text" readOnly={true} value={data.remoteUser!} />
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group className="mb-3" controlId="username">
|
||||||
|
<Form.Label>{t("log-in.callback.username")}</Form.Label>
|
||||||
|
<Form.Control name="username" type="text" required />
|
||||||
|
</Form.Group>
|
||||||
|
<input type="hidden" name="ticket" value={data.ticket!} />
|
||||||
|
<Button variant="primary" type="submit">
|
||||||
|
{t("log-in.callback.sign-up-button")}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</RemixForm>
|
||||||
|
);
|
||||||
|
}
|
|
@ -126,6 +126,9 @@ export default function LoginPage() {
|
||||||
{t("log-in.3rd-party.tumblr")}
|
{t("log-in.3rd-party.tumblr")}
|
||||||
</ListGroup.Item>
|
</ListGroup.Item>
|
||||||
)}
|
)}
|
||||||
|
<ListGroup.Item action href="/auth/log-in/fediverse">
|
||||||
|
{t("log-in.3rd-party.fediverse")}
|
||||||
|
</ListGroup.Item>
|
||||||
</ListGroup>
|
</ListGroup>
|
||||||
</div>
|
</div>
|
||||||
{!urls.email_enabled && <div className="col-lg-3"></div>}
|
{!urls.email_enabled && <div className="col-lg-3"></div>}
|
||||||
|
|
|
@ -0,0 +1,75 @@
|
||||||
|
import {
|
||||||
|
LoaderFunctionArgs,
|
||||||
|
json,
|
||||||
|
MetaFunction,
|
||||||
|
ActionFunctionArgs,
|
||||||
|
redirect,
|
||||||
|
} from "@remix-run/node";
|
||||||
|
import i18n from "~/i18next.server";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Form as RemixForm, useActionData } from "@remix-run/react";
|
||||||
|
import { Button, Form } from "react-bootstrap";
|
||||||
|
import serverRequest from "~/lib/request.server";
|
||||||
|
import { ApiError, ErrorCode } from "~/lib/api/error";
|
||||||
|
import ErrorAlert from "~/components/ErrorAlert";
|
||||||
|
|
||||||
|
export const meta: MetaFunction<typeof loader> = ({ data }) => {
|
||||||
|
return [{ title: `${data?.meta.title || "Log in with a Fediverse account"} • pronouns.cc` }];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
const t = await i18n.getFixedT(request);
|
||||||
|
|
||||||
|
return json({ meta: { title: t("log-in.fediverse.choose-title") } });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
const body = await request.formData();
|
||||||
|
const instance = body.get("instance") as string | null;
|
||||||
|
if (!instance)
|
||||||
|
return json({
|
||||||
|
error: {
|
||||||
|
status: 403,
|
||||||
|
code: ErrorCode.BadRequest,
|
||||||
|
message: "Invalid instance name",
|
||||||
|
} as ApiError,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await serverRequest<{ url: string }>(
|
||||||
|
"GET",
|
||||||
|
`/auth/fediverse?instance=${encodeURIComponent(instance)}`,
|
||||||
|
{
|
||||||
|
isInternal: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect(resp.url);
|
||||||
|
} catch (e) {
|
||||||
|
return json({ error: e as ApiError });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AuthFediversePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const data = useActionData<typeof action>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2>{t("log-in.fediverse.choose-form-title")}</h2>
|
||||||
|
{data?.error && <ErrorAlert error={data.error} />}
|
||||||
|
<RemixForm method="POST">
|
||||||
|
<Form as="div">
|
||||||
|
<Form.Group className="mb-3" controlId="instance">
|
||||||
|
<Form.Label>{t("log-in.fediverse-instance-label")}</Form.Label>
|
||||||
|
<Form.Control name="instance" type="text" />
|
||||||
|
</Form.Group>
|
||||||
|
<Button variant="primary" type="submit">
|
||||||
|
{t("log-in.fediverse-log-in-button")}
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</RemixForm>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -47,22 +47,31 @@
|
||||||
},
|
},
|
||||||
"log-in": {
|
"log-in": {
|
||||||
"callback": {
|
"callback": {
|
||||||
|
"invalid-ticket": "Invalid ticket (it might have been too long since you logged in), please <2>try again</2>.",
|
||||||
|
"invalid-username": "Invalid username",
|
||||||
|
"username-taken": "That username is already taken, please try something else.",
|
||||||
"title": {
|
"title": {
|
||||||
"discord-success": "Log in with Discord",
|
"discord-success": "Log in with Discord",
|
||||||
"discord-register": "Register with Discord"
|
"discord-register": "Register with Discord",
|
||||||
|
"fediverse-success": "Log in with a Fediverse account",
|
||||||
|
"fediverse-register": "Register with a Fediverse account"
|
||||||
},
|
},
|
||||||
"success": "Successfully logged in!",
|
"success": "Successfully logged in!",
|
||||||
"success-link": "Welcome back, <1>@{{username}}</1>!",
|
"success-link": "Welcome back, <1>@{{username}}</1>!",
|
||||||
"redirect-hint": "If you're not redirected to your profile in a few seconds, press the link above.",
|
"redirect-hint": "If you're not redirected to your profile in a few seconds, press the link above.",
|
||||||
"remote-username": {
|
"remote-username": {
|
||||||
"discord": "Your discord username"
|
"discord": "Your Discord username",
|
||||||
|
"fediverse": "Your Fediverse account"
|
||||||
},
|
},
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"sign-up-button": "Sign up",
|
"sign-up-button": "Sign up"
|
||||||
"invalid-ticket": "Invalid ticket (it might have been too long since you logged in with Discord), please <2>try again</2>.",
|
|
||||||
"invalid-username": "Invalid username",
|
|
||||||
"username-taken": "That username is already taken, please try something else."
|
|
||||||
},
|
},
|
||||||
|
"fediverse": {
|
||||||
|
"choose-title": "Log in with a Fediverse account",
|
||||||
|
"choose-form-title": "Choose a Fediverse instance"
|
||||||
|
},
|
||||||
|
"fediverse-instance-label": "Your Fediverse instance",
|
||||||
|
"fediverse-log-in-button": "Log in",
|
||||||
"title": "Log in",
|
"title": "Log in",
|
||||||
"form-title": "Log in with email",
|
"form-title": "Log in with email",
|
||||||
"email": "Email address",
|
"email": "Email address",
|
||||||
|
@ -74,7 +83,8 @@
|
||||||
"desc": "If you prefer, you can also log in with one of these services:",
|
"desc": "If you prefer, you can also log in with one of these services:",
|
||||||
"discord": "Log in with Discord",
|
"discord": "Log in with Discord",
|
||||||
"google": "Log in with Google",
|
"google": "Log in with Google",
|
||||||
"tumblr": "Log in with Tumblr"
|
"tumblr": "Log in with Tumblr",
|
||||||
|
"fediverse": "Log in with the Fediverse"
|
||||||
},
|
},
|
||||||
"invalid-credentials": "Invalid email address or password, please check your spelling and try again."
|
"invalid-credentials": "Invalid email address or password, please check your spelling and try again."
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,3 +1,18 @@
|
||||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||||
|
<s:String x:Key="/Default/CodeStyle/FileHeader/FileHeaderText/@EntryValue">Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published
|
||||||
|
by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.</s:String>
|
||||||
|
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EFeature_002EServices_002ECodeCleanup_002EFileHeader_002EFileHeaderSettingsMigrate/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=foxnouns/@EntryIndexedValue">True</s:Boolean>
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=foxnouns/@EntryIndexedValue">True</s:Boolean>
|
||||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=pronounscc/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
<s:Boolean x:Key="/Default/UserDictionary/Words/=pronounscc/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
Loading…
Reference in a new issue