Initial Commit

This commit is contained in:
Saphire 2023-05-31 06:57:07 +06:00
commit 4e51424c8e
50 changed files with 7617 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
obj
bin
appsettings.local.json
node_modules
Web/dist
.vscode/settings.json
wwwroot

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
}

View File

@ -0,0 +1,269 @@
namespace Lunar.Exchange.Lapis.Controllers;
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Web;
using Lunar.Exchange.Lapis.Data;
using Lunar.Exchange.Lapis.Data.Models;
using Lunar.Exchange.Lapis.Models;
using Lunar.Exchange.Lapis.Utilities;
//using Lunar.Exchange.Lapis.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
[ApiController]
[Route("/api/indieauth")]
public class IndieauthController : ControllerBase
{
private readonly ILogger<IndieauthController> Logger;
private readonly SignInManager<IdentityUser> SignInManager;
private readonly UserManager<IdentityUser> UserManager;
private readonly IUserStore<IdentityUser> UserStore;
private readonly LapisContext DbContext;
public IndieauthController(
ILogger<IndieauthController> logger,
SignInManager<IdentityUser> signInManager,
UserManager<IdentityUser> userManager,
IUserStore<IdentityUser> userStore,
LapisContext dbContext
)
{
Logger = logger;
SignInManager = signInManager;
UserManager = userManager;
UserStore = userStore;
DbContext = dbContext;
}
[HttpGet("metadata")]
[SnakeCaseConverterAttribute]
public IActionResult Metadata()
{
return Ok(new
{
Issuer = "https://auth.translunar.systems",
AuthorizationEndpoint = "https://auth.translunar.systems/indieauth",
TokenEndpoint = "https://auth.translunar.systems/api/indieauth/token",
IntrospectionEndpoint = "https://auth.translunar.systems/api/indieauth/introspect",
RevocationEndpoint = "https://auth.translunar.systems/api/indieauth/revoke",
UserinfoEndpoint = "https://auth.translunar.systems/api/indieauth/userinfo",
ResponseTypesSupported = new string[] {
"code"
},
GrantTypesSupported = new string[] {
"authorization_code"
},
CodeChallengeMethodsSupported = new string[] {
"S256"
},
AuthorizationResponseIssParameterSupported = true,
ServiceDocumentation = "https://auth.translunar.systems"
});
}
[HttpGet("/indieauth")]
public IActionResult RedirectToConsentPage()
{
var url = new UriBuilder("https://auth.translunar.systems/authorize");
url.Query = HttpContext.Request.QueryString.ToString();
return new RedirectResult(url.ToString(), false);
}
[HttpPost("authorize")]
[HttpPost("/indieauth")]
[SnakeCaseConverterAttribute]
[Consumes("application/x-www-form-urlencoded")]
public async Task<IActionResult> ClientGetProfile([FromForm] IndieauthAuthorizeModel model)
{
var result = await AuthorizeClient(model);
if (result is not OkResult)
return result;
// Nasty but eh
var requestCode = await DbContext.IndieauthCodes
.Include(c => c.Client)
.ThenInclude(l => l.Profile)
.SingleAsync(
c => c.Value == model.Code
&& c.Client.ClientId == model.ClientId
&& c.Client.RedirectUri == model.RedirectUri
&& c.Claimed != null
);
return Ok(new
{
Me = requestCode.Client.Profile.ProfileUrl
});
}
public async Task<IActionResult> AuthorizeClient(IndieauthAuthorizeModel model)
{
if (model.GrantType is null)
return BadRequest(new { Error = "invalid_request", ErrorMessage = "Missing grant_type argument" });
if (model.GrantType != "authorization_code")
return BadRequest(new { Error = "unsupported_grant_type", ErrorMessage = "Supported grant types: authorization_code" });
if (model.Code is null || model.ClientId is null || model.RedirectUri is null)
return BadRequest(new { Error = "invalid_request", ErrorMessage = "Missing required parameters" });
var requestCode = await DbContext.IndieauthCodes.SingleOrDefaultAsync(
c => c.Value == model.Code
&& c.Client.ClientId == model.ClientId
&& c.Client.RedirectUri == model.RedirectUri
&& c.Claimed == null
);
if (requestCode is null)
{
Logger.LogInformation("IndieAuth client token authorization failure: no such unclaimed code for given client info");
return BadRequest(new { Error = "invalid_grant", ErrorDescription = "Failed to verify client code" });
}
if (requestCode.Created.AddMinutes(3) < DateTime.UtcNow)
{
if (requestCode.Created.AddHours(1) < DateTime.UtcNow)
{
Logger.LogInformation("IndieAuth client token authorization failure: expired code");
return BadRequest(new { Error = "invalid_grant", ErrorDescription = "Failed to verify client code" });
}
else
{
Logger.LogInformation("IndieAuth client token authorization failure: recently expired code");
return BadRequest(new { Error = "invalid_grant", ErrorDescription = "Code expired" });
}
}
if (model.CodeVerifier is null != requestCode.CodeChallenge is null)
{
Logger.LogInformation("IndieAuth client token authorization failure: presence mismatch for PKCE \"code_verifier\" and \"code_challenge\"");
return BadRequest(new { Error = "invalid_grant", ErrorDescription = "Failed to verify client code" });
}
if (model.CodeVerifier is not null)
{
using (var sha256 = SHA256.Create())
{
var shaBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(model.CodeVerifier));
var challengeBytes = Base64UrlTextEncoder.Decode(requestCode.CodeChallenge!);
if (shaBytes is null || !shaBytes.Any())
{
Logger.LogError("IndieAuth client token authorization failure: hash output is null or empty");
return BadRequest(new { Error = "invalid_grant", ErrorDescription = "Failed to verify client code" });
}
if (!CryptographicOperations.FixedTimeEquals(shaBytes, challengeBytes))
{
Logger.LogInformation("IndieAuth client token authorization failure: PKCE verifier does not match the challenge");
return BadRequest(new { Error = "invalid_grant", ErrorDescription = "Failed to verify client code" });
}
}
}
requestCode.Claimed = DateTime.UtcNow;
await DbContext.SaveChangesAsync();
return Ok();
}
[HttpPost("request")]
[Authorize]
public async Task<IActionResult> AuthRequest(IndieauthRequestModel model)
{
var user = await UserManager.FindByNameAsync(User.Identity!.Name);
if (user is null)
// Shoudn't really happen, but just in case
return Unauthorized(new { Error = "Unauthorized" });
if (model.ProfileId is null)
return BadRequest(new { Error = "No profile selected" });
var profile = await DbContext.UserProfiles.SingleOrDefaultAsync(p => p.UserId == user.Id && p.Id == model.ProfileId);
if (profile is null)
return NotFound(new { Error = "No such profile for the current user" });
var client = new Uri(model.ClientId);
var redirect = new Uri(model.RedirectUri);
if (redirect.Host != client.Host || redirect.Scheme != client.Scheme || redirect.Port != client.Port)
return BadRequest(new { Error = "Redirect does not match the basic client data, checking not implemented yet, bailing" });
if (model.CodeChallenge is not null && model.CodeChallengeMethod is not null)
{
if (model.CodeChallengeMethod != "S256")
return BadRequest(new { Error = "Unsupported PKCE challenge method" });
if (Base64UrlTextEncoder.Decode(model.CodeChallenge).Length != 32)
return BadRequest(new { Error = "Invalid PKCE challenge data" });
}
if (model.Me is not null && profile.ProfileUrl != model.Me)
return BadRequest(new { Error = "Mismatched profile urls" });
var existing = await DbContext.IndieauthLinks.FirstOrDefaultAsync(l => l.Profile.UserId == user.Id && l.ClientId == model.ClientId);
// Probably not the best...
string? randomCode = null;
const string codeAlphabet = "abcdefghijklmnopqrtuvwxyzABCDEFGHIJKLMNOPQRTUVWXYZ1234567890!@#$%^&*()[]{}\\|-_;:'\",.`~ ";
const int size = 48;
using (var rng = RandomNumberGenerator.Create())
{
byte[] randomBytes = new byte[4 * size];
rng.GetBytes(randomBytes);
StringBuilder result = new StringBuilder(size);
for (int i = 0; i < size; i++)
{
var rnd = BitConverter.ToUInt32(randomBytes, i * 4);
var idx = rnd % codeAlphabet.Length;
result.Append(codeAlphabet[((int)idx)]);
}
randomCode = result.ToString();
}
if (randomCode is null || randomCode.Length != size)
throw new Exception("Code creation error");
var linkEntity = existing ?? DbContext.IndieauthLinks.Add(new IndieauthClient
{
ProfileId = profile.Id,
ClientId = model.ClientId,
RedirectUri = model.RedirectUri,
FirstSeen = DateTime.UtcNow,
Scope = model.Scope
}).Entity;
linkEntity.LastSeen = DateTime.UtcNow;
DbContext.IndieauthCodes.Add(new IndieauthRequest
{
Client = linkEntity,
Value = randomCode,
CodeChallenge = model.CodeChallenge,
CodeChallengeMethod = model.CodeChallengeMethod,
Created = DateTime.UtcNow,
});
await DbContext.SaveChangesAsync();
var redirectTo = new UriBuilder(model.RedirectUri);
var query = HttpUtility.ParseQueryString(redirectTo.Query);
query.Set("code", randomCode);
query.Set("state", model.State);
query.Set("iss", "https://auth.translunar.systems/");
redirectTo.Query = query.ToString();
return Ok(new { Message = "Successfully authorized", RedirectUri = redirectTo.ToString() });
}
}

View File

@ -0,0 +1,132 @@
namespace Lunar.Exchange.Lapis.Controllers;
using System.Security.Claims;
using Lunar.Exchange.Lapis.Data;
using Lunar.Exchange.Lapis.Models;
//using Lunar.Exchange.Lapis.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
[ApiController]
[Authorize]
[Route("/api/user")]
public class UserController : ControllerBase
{
private readonly ILogger<UserController> Logger;
private readonly SignInManager<IdentityUser> SignInManager;
private readonly UserManager<IdentityUser> UserManager;
private readonly IUserStore<IdentityUser> UserStore;
private readonly LapisContext DbContext;
public UserController(
ILogger<UserController> logger,
SignInManager<IdentityUser> signInManager,
UserManager<IdentityUser> userManager,
IUserStore<IdentityUser> userStore,
LapisContext dbContext
)
{
Logger = logger;
SignInManager = signInManager;
UserManager = userManager;
UserStore = userStore;
DbContext = dbContext;
}
[HttpPost("login")]
[AllowAnonymous]
public async Task<IActionResult> Login(LoginModel loginModel)
{
var user = await UserManager.FindByNameAsync(loginModel.Username);
if (user == null)
{
return Unauthorized(new { error = "Invalid email or password" });
}
var result = await SignInManager.PasswordSignInAsync(
user,
loginModel.Password,
isPersistent: true,
lockoutOnFailure: false
);
if (result.RequiresTwoFactor)
return Unauthorized(new { error = "Continue with two-factor auth" });
if (result.IsLockedOut || result.IsNotAllowed)
return Unauthorized(new { error = "Account locked" });
if (!result.Succeeded)
return Unauthorized(new { error = "Invalid email or password" });
return Ok(new
{
Message = "Logged in successfully"
});
}
[HttpPost("logout")]
[AllowAnonymous]
public async Task<IActionResult> Logout()
{
if (User.Identity is null)
return Ok(new { });
var user = await UserManager.FindByNameAsync(User.Identity!.Name);
if (user is null)
return Ok(new { });
await HttpContext.SignOutAsync(IdentityConstants.ApplicationScheme);
return Ok(new { });
}
[HttpPost("register")]
[AllowAnonymous]
public async Task<IActionResult> Register(LoginModel loginModel)
{
var user = new IdentityUser();
await UserStore.SetUserNameAsync(user, loginModel.Username, default);
var result = await UserManager.CreateAsync(user, loginModel.Password);
return Ok(new
{
result.Succeeded,
result.Errors
});
}
[HttpGet("")]
public async Task<IActionResult> UserInfo()
{
var user = await UserManager.FindByNameAsync(User.Identity!.Name);
var profiles = await DbContext.UserProfiles
.Where(u => u.UserId == user.Id)
.Select(p => new
{
p.Id,
p.ProfileUrl,
Clients = p.Clients.Select(c => new
{
c.Id,
c.ClientId,
c.RedirectUri,
c.Scope,
c.FirstSeen,
c.LastSeen
})
})
.ToListAsync();
return Ok(new
{
Username = user.UserName,
Profiles = profiles
});
}
}

22
Data/LapisContext.cs Normal file
View File

@ -0,0 +1,22 @@
namespace Lunar.Exchange.Lapis.Data;
using System.Reflection;
using Lunar.Exchange.Lapis.Data.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
public class LapisContext : IdentityDbContext<IdentityUser>
{
public DbSet<IndieauthClient> IndieauthLinks { get; set; } = null!;
public DbSet<IndieauthRequest> IndieauthCodes { get; set; } = null!;
public DbSet<UserProfile> UserProfiles { get; set; } = null!;
public LapisContext(DbContextOptions<LapisContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
}
}

View File

@ -0,0 +1,21 @@
namespace Lunar.Exchange.Lapis.Data.Models;
public class IndieauthClient
{
public int Id { get; set; }
public int ProfileId { get; set; }
public UserProfile Profile { get; set; } = null!;
public string ClientId { get; set; } = null!;
public string RedirectUri { get; set; } = null!;
// Maybe store it as several entries elsewhere? Good enough for MVP
public string? Scope { get; set; }
public DateTime FirstSeen { get; set; }
public DateTime LastSeen { get; set; }
public IEnumerable<IndieauthRequest> Codes { get; set; } = null!;
}

View File

@ -0,0 +1,16 @@
namespace Lunar.Exchange.Lapis.Data.Models;
public class IndieauthRequest
{
public int Id { get; set; }
public int ClientId { get; set; }
public IndieauthClient Client { get; set; } = null!;
public string Value { get; set; } = null!;
public string CodeChallenge { get; set; } = null!;
public string CodeChallengeMethod { get; set; } = null!;
public DateTime Created { get; set; }
public DateTime? Claimed { get; set; }
}

View File

@ -0,0 +1,15 @@
using Microsoft.AspNetCore.Identity;
namespace Lunar.Exchange.Lapis.Data.Models;
public class UserProfile
{
public int Id { get; set; }
public string UserId { get; set; } = null!;
public IdentityUser User { get; set; } = null!;
public string ProfileUrl { get; set; } = null!;
public IEnumerable<IndieauthClient> Clients { get; set; } = null!;
}

View File

@ -0,0 +1,402 @@
// <auto-generated />
using System;
using Lunar.Exchange.Lapis.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Lunar.Exchange.Lapis.Migrations
{
[DbContext(typeof(LapisContext))]
[Migration("20221024165358_InitialMigration")]
partial class InitialMigration
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "6.0.4");
modelBuilder.Entity("Lunar.Exchange.Lapis.Data.Models.IndieauthClient", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("FirstSeen")
.HasColumnType("TEXT");
b.Property<DateTime>("LastSeen")
.HasColumnType("TEXT");
b.Property<int>("ProfileId")
.HasColumnType("INTEGER");
b.Property<string>("RedirectUri")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Scope")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ProfileId");
b.ToTable("IndieauthLinks");
});
modelBuilder.Entity("Lunar.Exchange.Lapis.Data.Models.IndieauthRequest", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("Claimed")
.HasColumnType("TEXT");
b.Property<int>("ClientId")
.HasColumnType("INTEGER");
b.Property<string>("CodeChallenge")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CodeChallengeMethod")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClientId");
b.ToTable("IndieauthCodes");
});
modelBuilder.Entity("Lunar.Exchange.Lapis.Data.Models.UserProfile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ProfileUrl")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserProfiles");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Lunar.Exchange.Lapis.Data.Models.IndieauthClient", b =>
{
b.HasOne("Lunar.Exchange.Lapis.Data.Models.UserProfile", "Profile")
.WithMany("Clients")
.HasForeignKey("ProfileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Profile");
});
modelBuilder.Entity("Lunar.Exchange.Lapis.Data.Models.IndieauthRequest", b =>
{
b.HasOne("Lunar.Exchange.Lapis.Data.Models.IndieauthClient", "Client")
.WithMany("Codes")
.HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Client");
});
modelBuilder.Entity("Lunar.Exchange.Lapis.Data.Models.UserProfile", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lunar.Exchange.Lapis.Data.Models.IndieauthClient", b =>
{
b.Navigation("Codes");
});
modelBuilder.Entity("Lunar.Exchange.Lapis.Data.Models.UserProfile", b =>
{
b.Navigation("Clients");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,311 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lunar.Exchange.Lapis.Migrations
{
public partial class InitialMigration : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
UserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: true),
SecurityStamp = table.Column<string>(type: "TEXT", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true),
PhoneNumber = table.Column<string>(type: "TEXT", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
LockoutEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
AccessFailedCount = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
RoleId = table.Column<string>(type: "TEXT", nullable: false),
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UserId = table.Column<string>(type: "TEXT", nullable: false),
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
ProviderKey = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
ProviderDisplayName = table.Column<string>(type: "TEXT", nullable: true),
UserId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<string>(type: "TEXT", nullable: false),
RoleId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<string>(type: "TEXT", nullable: false),
LoginProvider = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
Value = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserProfiles",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UserId = table.Column<string>(type: "TEXT", nullable: false),
ProfileUrl = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserProfiles", x => x.Id);
table.ForeignKey(
name: "FK_UserProfiles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "IndieauthLinks",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ProfileId = table.Column<int>(type: "INTEGER", nullable: false),
ClientId = table.Column<string>(type: "TEXT", nullable: false),
RedirectUri = table.Column<string>(type: "TEXT", nullable: false),
Scope = table.Column<string>(type: "TEXT", nullable: true),
FirstSeen = table.Column<DateTime>(type: "TEXT", nullable: false),
LastSeen = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_IndieauthLinks", x => x.Id);
table.ForeignKey(
name: "FK_IndieauthLinks_UserProfiles_ProfileId",
column: x => x.ProfileId,
principalTable: "UserProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "IndieauthCodes",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ClientId = table.Column<int>(type: "INTEGER", nullable: false),
Value = table.Column<string>(type: "TEXT", nullable: false),
CodeChallenge = table.Column<string>(type: "TEXT", nullable: false),
CodeChallengeMethod = table.Column<string>(type: "TEXT", nullable: false),
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
Claimed = table.Column<DateTime>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_IndieauthCodes", x => x.Id);
table.ForeignKey(
name: "FK_IndieauthCodes_IndieauthLinks_ClientId",
column: x => x.ClientId,
principalTable: "IndieauthLinks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_IndieauthCodes_ClientId",
table: "IndieauthCodes",
column: "ClientId");
migrationBuilder.CreateIndex(
name: "IX_IndieauthLinks_ProfileId",
table: "IndieauthLinks",
column: "ProfileId");
migrationBuilder.CreateIndex(
name: "IX_UserProfiles_UserId",
table: "UserProfiles",
column: "UserId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "IndieauthCodes");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "IndieauthLinks");
migrationBuilder.DropTable(
name: "UserProfiles");
migrationBuilder.DropTable(
name: "AspNetUsers");
}
}
}

View File

@ -0,0 +1,400 @@
// <auto-generated />
using System;
using Lunar.Exchange.Lapis.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Lunar.Exchange.Lapis.Migrations
{
[DbContext(typeof(LapisContext))]
partial class LapisContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "6.0.4");
modelBuilder.Entity("Lunar.Exchange.Lapis.Data.Models.IndieauthClient", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("FirstSeen")
.HasColumnType("TEXT");
b.Property<DateTime>("LastSeen")
.HasColumnType("TEXT");
b.Property<int>("ProfileId")
.HasColumnType("INTEGER");
b.Property<string>("RedirectUri")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Scope")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ProfileId");
b.ToTable("IndieauthLinks");
});
modelBuilder.Entity("Lunar.Exchange.Lapis.Data.Models.IndieauthRequest", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("Claimed")
.HasColumnType("TEXT");
b.Property<int>("ClientId")
.HasColumnType("INTEGER");
b.Property<string>("CodeChallenge")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CodeChallengeMethod")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ClientId");
b.ToTable("IndieauthCodes");
});
modelBuilder.Entity("Lunar.Exchange.Lapis.Data.Models.UserProfile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ProfileUrl")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UserProfiles");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Lunar.Exchange.Lapis.Data.Models.IndieauthClient", b =>
{
b.HasOne("Lunar.Exchange.Lapis.Data.Models.UserProfile", "Profile")
.WithMany("Clients")
.HasForeignKey("ProfileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Profile");
});
modelBuilder.Entity("Lunar.Exchange.Lapis.Data.Models.IndieauthRequest", b =>
{
b.HasOne("Lunar.Exchange.Lapis.Data.Models.IndieauthClient", "Client")
.WithMany("Codes")
.HasForeignKey("ClientId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Client");
});
modelBuilder.Entity("Lunar.Exchange.Lapis.Data.Models.UserProfile", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lunar.Exchange.Lapis.Data.Models.IndieauthClient", b =>
{
b.Navigation("Codes");
});
modelBuilder.Entity("Lunar.Exchange.Lapis.Data.Models.UserProfile", b =>
{
b.Navigation("Clients");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Mvc;
namespace Lunar.Exchange.Lapis.Models;
public class IndieauthAuthorizeModel
{
[FromForm(Name = "code")]
public string? Code { get; set; } = null!;
[FromForm(Name = "client_id")]
public string? ClientId { get; set; } = null!;
[FromForm(Name = "redirect_uri")]
public string? RedirectUri { get; set; } = null!;
[FromForm(Name = "grant_type")]
public string? GrantType { get; set; } = null!;
[FromForm(Name = "code_verifier")]
public string? CodeVerifier { get; set; }
}

View File

@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
namespace Lunar.Exchange.Lapis.Models;
public class IndieauthRequestModel
{
public string ResponseType { get; set; } = null!;
public string ClientId { get; set; } = null!;
public string RedirectUri { get; set; } = null!;
public string State { get; set; } = null!;
public string CodeChallenge { get; set; } = null!;
public string CodeChallengeMethod { get; set; } = null!;
public string? Scope { get; set; }
public string? Me { get; set; }
public int? ProfileId { get; set; }
}

6
Models/LapisOptions.cs Normal file
View File

@ -0,0 +1,6 @@
namespace Lunar.Exchange.Lapis.Models;
class LapisOptions
{
}

7
Models/LoginModel.cs Normal file
View File

@ -0,0 +1,7 @@
namespace Lunar.Exchange.Lapis.Models;
public class LoginModel
{
public string Username { get; set; } = null!;
public string Password { get; set; } = null!;
}

97
Program.cs Normal file
View File

@ -0,0 +1,97 @@
using Lunar.Exchange.Lapis.Data;
using Lunar.Exchange.Lapis.Models;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
var lapisConfiguration = new LapisOptions();
builder.Configuration.Bind("Lapis", lapisConfiguration);
builder.Services.AddDbContext<LapisContext>(
options => options.UseSqlite(
builder.Configuration.GetConnectionString("LapisContext")
)
);
builder.Services
.AddAntiforgery(options =>
{
options.HeaderName = "X-LAPIS-XSRF-TOKEN";
options.Cookie.Name = "__Host-X-LAPIS-XSRF-TOKEN";
options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict;
options.Cookie.SecurePolicy = Microsoft.AspNetCore.Http.CookieSecurePolicy.Always;
})
.AddIdentityCore<IdentityUser>(o =>
{
o.Stores.MaxLengthForKeys = 128;
})
.AddSignInManager()
.AddDefaultTokenProviders()
.AddEntityFrameworkStores<LapisContext>();
builder.Services
.AddAuthentication("Identity.Application")
.AddCookie("Identity.Application", options =>
{
options.AccessDeniedPath = null;
options.LoginPath = null;
options.LogoutPath = null;
options.Events.OnRedirectToAccessDenied = redirectContext =>
{
redirectContext.Response.StatusCode = 403;
return Task.CompletedTask;
};
options.Events.OnRedirectToLogin = redirectContext =>
{
Console.WriteLine($"{redirectContext}");
redirectContext.Response.StatusCode = 401;
return Task.CompletedTask;
};
});
builder.Services
.AddControllers();
var app = builder.Build();
{
using var scope = app.Services.CreateScope();
using var context = scope.ServiceProvider.GetRequiredService<LapisContext>();
await context.Database.MigrateAsync();
}
app.Use(async (context, next) =>
{
if (context.Request.Method != "GET")
{
await next(context);
return;
}
var path = context.Request.Path;
var redirect = path.Value switch
{
"/forgot" or "/login" or "/logout" or "/register" => true,
"/forgot/" or "/login/" or "/logout/" or "/register/" => true,
"/authorize" or "/indieauth" => true,
"/authorize/" or "/indieauth/" => true,
_ => false
};
if (redirect)
context.Request.Path = "/";
await next(context);
});
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.MapDefaultControllerRoute();
app.Run();

View File

@ -0,0 +1,12 @@
{
"profiles": {
"lapis": {
"commandName": "Project",
"dotnetRunMessages": true,
"applicationUrl": "https://localhost:7050;http://localhost:7049",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,15 @@
class IndieauthResolver
{
IndieauthResolver()
{
}
public void Discover(Uri userProfile)
{
}
}

View File

@ -0,0 +1,37 @@
namespace Lunar.Exchange.Lapis.Utilities;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.Formatters;
public class SnakeCaseConverterAttribute : ActionFilterAttribute
{
JsonSerializerOptions SerializerOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = new SnakeCaseNamingPolicy()
};
public override void OnResultExecuting(ResultExecutingContext context)
{
if (context.Result is ObjectResult objectResult)
{
var jsonFormatter = new SystemTextJsonOutputFormatter(SerializerOptions);
objectResult.Formatters.Add(jsonFormatter);
}
base.OnResultExecuting(context);
}
}
public class SnakeCaseNamingPolicy : JsonNamingPolicy
{
public static SnakeCaseNamingPolicy Instance { get; } = new SnakeCaseNamingPolicy();
public override string ConvertName(string name)
{
// Conversion to other naming convention goes here. Like SnakeCase, KebabCase etc.
return string.Concat(name.Select((x, i) => i > 0 && char.IsUpper(x) ? "_" + x.ToString() : x.ToString())).ToLower();
}
}

4
Web/env.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
/// <reference types="vite/client" />
const __COMMIT_HASH__: string | null;
const __APP_VERSION__: string;

23
Web/index.html Normal file
View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lapis Auth</title>
</head>
<body>
<div id="app"></div>
<div id="modals"></div>
<noscript>
<main>
This application requires JavaScript enabled. It's a personal
thing made quickly without wanting to use the server-side
templates only to then also spend time to figure out out how to
get the input in those bound properly. Sorry!
</main>
</noscript>
<footer id="footer" class="relative w-full"></footer>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

4414
Web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
Web/package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "lapis-frontend",
"version": "0.1.0",
"scripts": {
"dev": "vite",
"build": "run-p type-check build-only",
"preview": "vite preview --port 4173",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@heroicons/vue": "^2.0.10",
"pinia": "^2.0.21",
"vue": "^3.2.38",
"vue-router": "^4.1.5"
},
"devDependencies": {
"@types/node": "^16.11.56",
"@vitejs/plugin-vue": "^3.0.3",
"@vue/tsconfig": "^0.1.3",
"autoprefixer": "^10.4.8",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.16",
"tailwindcss": "^3.1.8",
"typescript": "~4.7.4",
"vite": "^3.0.9",
"vue-tsc": "^0.40.7"
},
"prettier": {
"trailingComma": "es5",
"tabWidth": 4,
"semi": true,
"singleQuote": false
}
}

6
Web/postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
Web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

60
Web/src/App.vue Normal file
View File

@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed } from "@vue/reactivity";
import { nextTick, onMounted, watch } from "vue";
import { RouterView, useRoute } from "vue-router";
const route = useRoute();
const body = document.getElementsByTagName("body")[0];
body.classList.add("loaded");
function footerUpdate(appearing: boolean) {
body.classList.toggle("footer-shown", appearing);
}
const showFooter = computed(
() =>
(route.meta?.showFooter as boolean) ??
[...route.matched].reverse().find((m) => m.meta?.showFooter)?.meta
?.showFooter ??
false
);
const version =
__APP_VERSION__ + (__COMMIT_HASH__ ? "-" + __COMMIT_HASH__ : "");
</script>
<template>
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
<teleport to="#footer">
<transition
@after-enter="footerUpdate(true)"
@after-leave="footerUpdate(false)"
name="fade"
mode="out-in"
>
<div
v-if="showFooter"
class="absolute bottom-0 left-0 right-0 flex justify-center py-1"
>
<span>lapis v{{ version }}</span>
</div>
</transition>
</teleport>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

18
Web/src/assets/base.css Normal file
View File

@ -0,0 +1,18 @@
:root {
color-scheme: dark;
}
body {
--bg-color: #181818;
min-height: 100vh;
color: rgba(235, 235, 235, 0.64);
background: var(--bg-color);
line-height: 1.6;
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

1
Web/src/assets/logo.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69" xmlns:v="https://vecta.io/nano"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 308 B

23
Web/src/assets/main.css Normal file
View File

@ -0,0 +1,23 @@
@import "./base.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
#app {
display: flex;
font-weight: normal;
margin: 0 auto;
flex: 1 0 auto;
place-items: center;
}
.footer-shown > #app {
margin-bottom: 2em;
}
body {
display: flex;
flex-direction: column;
place-items: center center;
}

View File

@ -0,0 +1,76 @@
<script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/24/outline";
import { nextTick, onMounted, ref, watch } from "vue";
const props = defineProps<{
modelValue: string | null;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: string | null): void;
}>();
const finishedChanging = ref<number | null>(null);
const innerElement = ref<HTMLDivElement | null>(null);
const style = ref<string | undefined>("height: 0px");
const cachedValue = ref<string | null>(props.modelValue ?? null);
watch(
() => props.modelValue,
async (newValue, oldValue) => {
style.value = `height: ${
oldValue ? innerElement.value?.clientHeight ?? 0 : 0
}`;
if (finishedChanging.value) clearTimeout(finishedChanging.value);
if (newValue) cachedValue.value = newValue;
await nextTick();
style.value = `height: ${
newValue ? innerElement.value?.clientHeight ?? 0 : 0
}px`;
finishedChanging.value = setTimeout(() => {
finishedChanging.value = null;
cachedValue.value = newValue;
}, 200);
}
);
onMounted(async () => {
if (finishedChanging.value) clearTimeout(finishedChanging.value);
style.value = `height: ${
props.modelValue ? innerElement.value?.clientHeight ?? 0 : 0
}px`;
finishedChanging.value = setTimeout(
() => (finishedChanging.value = null),
205
);
});
</script>
<template>
<div
v-if="modelValue || cachedValue"
class="collapsible mx-1 bg-red-900 text-gray-50 rounded relative overflow-hidden"
:style="style"
>
<div ref="innerElement" class="px-3 py-2 whitespace-pre-wrap">
<button
title="Close Collapsible"
type="button"
@click="emit('update:modelValue', null)"
class="float-right ml-2"
>
<XCircleIcon class="h-6 w-6 text-gray-50" /></button
>{{ cachedValue }}
</div>
</div>
</template>
<style>
.collapsible {
transition: height 0.2s ease-out;
}
</style>

View File

@ -0,0 +1,185 @@
<script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/24/outline";
import { nextTick, onMounted, onUnmounted, ref, watch } from "vue";
const props = defineProps<{
modelValue: boolean;
title?: string;
}>();
const closeButton = ref<HTMLButtonElement>();
const modalBody = ref<HTMLDivElement>();
const ariaClose = ref<HTMLDivElement>();
let previousFocus: HTMLElement | null = null;
let lastFocusable: HTMLElement;
let escListener: (event: KeyboardEvent) => void;
let closing = false;
function close() {
if (!props.modelValue) return;
closing = true;
emit("update:modelValue", false);
emit("closed");
previousFocus?.focus();
previousFocus = null;
}
function closeEsc() {
const defaultElement = getDefaultFocus();
if (document.activeElement == document.body) {
close();
return;
}
if (document.activeElement != defaultElement) {
(defaultElement as HTMLElement).focus();
return;
}
close();
}
function getDefaultFocus() {
return (
(modalBody.value?.getElementsByClassName(
"modal-default-element"
)[0] as HTMLElement) ??
closeButton.value ??
ariaClose.value!
);
}
function tabCapture(event: KeyboardEvent) {
const active = document.activeElement;
const close = closeButton.value ?? ariaClose.value!;
if (event.shiftKey && active == close) {
event.preventDefault();
lastFocusable.focus();
}
if (!event.shiftKey && active == lastFocusable) {
event.preventDefault();
close.focus();
}
}
onMounted(() => {
escListener = (event) => {
if (event.code != "Escape") return;
if (props.modelValue == false) return;
closeEsc();
};
document.addEventListener("keydown", escListener);
});
onUnmounted(() => {
document.removeEventListener("keydown", escListener);
});
watch(
() => props.modelValue,
async (value, old) => {
previousFocus =
document.activeElement == document.body
? null
: (document.activeElement as HTMLElement);
console.log(value, old);
if (!value || old) return;
await nextTick();
const defaultElement = getDefaultFocus();
console.log(defaultElement);
defaultElement.focus();
lastFocusable = ariaClose.value!;
const focusable = [
...(modalBody.value?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
) ?? []),
] as HTMLElement[];
lastFocusable =
focusable.length > 1
? focusable[focusable.length - 1]
: lastFocusable;
}
);
const emit = defineEmits<{
(e: "update:modelValue", value: boolean): void;
(e: "closed"): void;
}>();
</script>
<template>
<teleport to="#modals">
<transition appear name="fade">
<div
id="modal-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
v-if="modelValue"
@click="close()"
@keydown.tab="tabCapture"
class="absolute inset-0 bg-gray-900/50 flex h-screen items-center justify-center"
>
<div
v-if="!closeButton"
ref="ariaClose"
tabindex="0"
role="button"
aria-label="Close modal"
@click.stop="close()"
@keydown.enter="close()"
@keydown.space="close()"
></div>
<div
ref="modalBody"
@click.stop=""
class="w-96 max-h-[80%] bg-slate-700 rounded divide-y divide-gray-600"
>
<div>
<slot name="header">
<h3 id="modal-title" class="p-2">
{{ title ?? "Modal" }}
<button
ref="closeButton"
aria-label="Close"
@click="close()"
class="float-right ml-2 text-red-400 active:text-red-500 focus:outline outline-2 outline-offset-2 rounded transition-color transition-100 ease-in"
>
<x-circle-icon class="w-6 h-6" />
</button>
</h3>
</slot>
</div>
<div class="p-2 flex flex-col">
<slot><p>Empty modal</p></slot>
</div>
<div v-if="$slots.footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</transition>
</teleport>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@ -0,0 +1,43 @@
<script setup lang="ts">
import { ref } from "vue";
import type { RouteLocationRaw } from "vue-router";
const props = defineProps<{
disabled?: boolean;
text?: string;
to?: RouteLocationRaw;
customClass?: string;
type?: string;
}>();
const button = ref(null);
const emit = defineEmits<{
(e: "click", value: MouseEvent): void;
}>();
const eventHandler = (event: MouseEvent) => {
emit("click", event);
};
</script>
<template>
<component
ref="button"
:is="to ? 'RouterLink' : 'button'"
@click="eventHandler"
:to="to ? to : undefined"
:disabled="disabled || false"
class="px-1 py-2 border transition-colors transition-200 ease-in"
:class="[
to ? 'text-center' : '',
customClass ??
'bg-slate-800 border-slate-700 disabled:bg-zinc-800 disabled:border-zinc-700 hover:bg-slate-700 hover:border-slate-600',
]"
:type="type"
>
<slot>
{{ text || "" }}
</slot>
</component>
</template>

View File

@ -0,0 +1,215 @@
<script setup lang="ts">
import type {
FileEntry,
FilesystemEntry,
FileTree,
FolderEntry,
} from "@/stores/files";
import { computed } from "@vue/reactivity";
import { nextTick, ref } from "vue";
const props = defineProps<{
tree: FileTree;
level?: number;
}>();
const emit = defineEmits<{
(e: "file-click", file: FileEntry): void;
(e: "focus-up", file: FileTree): void;
(e: "focus-previous", file: FileTree): void;
(e: "focus-next", file: FileTree): void;
(e: "focused", file: FileTree): void;
}>();
const focusedEntry = ref<number | null>(null);
const innerEntries = ref<
(
| HTMLDivElement
| {
innerPrevious: () => void;
innerNext: () => void;
outerFocusSelf: (next: boolean | null) => void;
}
)[]
>([]);
const root = props.tree.type == "filesystem";
const open = ref(root);
const treeLabel = ref<HTMLDivElement>();
async function toggleFolder() {
open.value = !open.value;
await nextTick();
treeLabel.value?.focus();
emit("focused", props.tree);
}
function innerPrevious() {
console.log("prev from", focusedEntry.value);
if (focusedEntry.value === null) {
focusedEntry.value = props.tree.files.length - 1;
emit("focused", props.tree);
} else focusedEntry.value--;
if (focusedEntry.value >= 0) {
focusEntry(focusedEntry.value, false);
} else {
focusSelf();
}
}
function innerNext() {
if (focusedEntry.value === null) {
focusedEntry.value = 0;
emit("focused", props.tree);
} else focusedEntry.value++;
if (focusedEntry.value <= props.tree.files.length - 1) {
focusEntry(focusedEntry.value, true);
} else {
outerNext();
}
}
function outerUp() {
focusedEntry.value = null;
emit("focus-up", props.tree);
}
function outerPrevious() {
focusedEntry.value = null;
emit("focus-previous", props.tree);
}
function outerNext() {
focusedEntry.value = null;
emit("focus-next", props.tree);
}
function focusEntry(index: number, next: boolean | null) {
const entryElem = innerEntries.value[index];
console.log(index, entryElem, props.tree.files[index]);
if (entryElem instanceof HTMLElement) {
if (document.activeElement != entryElem) entryElem.focus();
} else {
entryElem.outerFocusSelf(next);
}
}
function focusFsEntry(tree: FilesystemEntry) {
const idx = props.tree.files.indexOf(tree);
if (idx == -1) throw "Corrupt file tree or file explorer";
focusedEntry.value = idx;
return idx;
}
function focusSelf() {
focusedEntry.value = null;
treeLabel.value?.focus();
}
function outerFocusSelf(next: boolean | null) {
if (next || !open.value || (next === null && focusedEntry.value === null)) {
if (!root) focusSelf();
else {
focusedEntry.value = 0;
focusEntry(0, null);
}
return;
}
if (next !== null) {
focusedEntry.value = null;
innerPrevious();
return;
}
focusEntry(focusedEntry.value!, null);
}
function openFile(file: FileEntry) {
emit("focused", props.tree);
focusedEntry.value = focusFsEntry(file);
emit("file-click", file as FileEntry);
}
const itemId = computed(() => {
return root ? "root" : "" + (props.tree as FolderEntry).sessionUid;
});
defineExpose({
innerPrevious,
innerNext,
outerFocusSelf,
});
</script>
<template>
<div
:id="`folder-${itemId}`"
:role="root ? 'tree' : 'treeitem'"
:aria-setsize="tree.files.length"
:aria-labelledby="root ? undefined : `folder-label-${itemId}`"
:aria-level="root ? 1 : level"
:aria-expanded="root || tree.files.length > 0 ? open : undefined"
>
<div
v-if="tree.type == 'folder'"
ref="treeLabel"
@click="toggleFolder()"
@keydown.stop.up="emit('focus-previous', tree)"
@keydown.stop.down="open ? innerNext() : emit('focus-next', tree)"
@keydown.stop.left="open ? toggleFolder() : outerUp()"
@keydown.stop.right="open ? innerNext() : toggleFolder()"
:id="`folder-label-${itemId}`"
class="p-1 rounded whitespace-nowrap text-ellipsis overflow-hidden focus:outline outline-2 outline-offset-2"
:class="{
'bg-cyan-900/40': !open,
'bg-cyan-900/20': open,
}"
tabindex="-1"
>
{{ (tree as FolderEntry).name }}
</div>
<div :class="{ 'ml-4': !root }" v-if="open">
<template v-for="(file, idx) in tree.files">
<a
v-if="file.type == 'file'"
ref="innerEntries"
@click="openFile(file as FileEntry)"
@keydown.stop.up="innerPrevious()"
@keydown.stop.down="innerNext()"
@keydown.stop.left="focusSelf()"
@keydown.stop.right="innerNext()"
class="block my-1 p-1 whitespace-nowrap text-ellipsis overflow-hidden focus:outline outline-2 outline-offset-2"
tabindex="-1"
role="treeitem"
:aria-posinset="idx"
>
{{ file.name }}
</a>
<vault-tree
v-else
ref="innerEntries"
@file-click="(file) => emit('file-click', file)"
@focus-up="focusSelf()"
@focused="
(innerTree) => (
focusFsEntry(innerTree as FolderEntry), emit('focused', tree)
)
"
@focus-previous="
(tree) => (focusFsEntry(tree as FolderEntry), innerPrevious())
"
@focus-next="(tree) => (focusFsEntry(tree as FolderEntry), innerNext())"
class="my-1"
:tree="(file as FolderEntry)"
:level="(level ?? 1) + 1"
:aria-posinset="idx"
/>
</template>
</div>
</div>
</template>

15
Web/src/main.ts Normal file
View File

@ -0,0 +1,15 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from '@/App.vue'
import router from '@/router'
import '@/assets/main.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

112
Web/src/router.ts Normal file
View File

@ -0,0 +1,112 @@
import { createRouter, createWebHistory } from "vue-router";
import HomeView from "@/views/HomeView.vue";
import LoginView from "@/views/LoginView.vue";
import AuthorizeView from "@/views/AuthorizeView.vue";
import useAuthStore from "./stores/auth";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
name: "home",
component: HomeView,
meta: {
showFooter: true,
},
},
{
path: "/authorize",
name: "authorize",
component: AuthorizeView,
meta: {
showFooter: true,
},
},
{
path: "/login",
name: "login",
component: LoginView,
meta: {
guest: true,
guestOnly: true,
showFooter: true,
},
},
{
path: "/register",
name: "register",
component: () => import("@/views/RegisterView.vue"),
meta: {
guest: true,
guestOnly: true,
showFooter: true,
},
},
{
path: "/logout",
name: "logout",
component: () => import("@/views/LogoutView.vue"),
meta: {
showFooter: true,
},
},
{
path: "/offline",
name: "offline",
component: () => import("@/views/OfflineView.vue"),
meta: {
showFooter: true,
},
},
{
path: "/:pathMatch(.*)*",
name: "notfound",
component: () => import("@/views/NotFoundView.vue"),
meta: {
showFooter: true,
},
},
],
});
router.beforeEach(async (to, from) => {
const auth = useAuthStore();
if (!from.name) {
const showFooter =
(to.meta?.showFooter as boolean) ??
[...to.matched].reverse().find((m) => m.meta?.showFooter)?.meta
?.showFooter ??
false;
document
.getElementsByTagName("body")[0]
.classList.toggle("footer-shown", showFooter);
}
try {
if (!auth.userInfo) await auth.ready;
} catch (error) {
if (to.name == "offline") return;
auth.savePath(to.fullPath);
console.log(auth.savedPath);
console.error(error);
return "/offline";
}
if (auth.userInfo) {
if (to.meta?.guestOnly) {
return "/";
}
} else {
if (!to.meta?.guest) {
return "/login";
}
}
});
export default router;

63
Web/src/stores/auth.ts Normal file
View File

@ -0,0 +1,63 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import { get, send } from "@/utils";
interface UserProfiles {
id: number;
profileUrl: string;
}
interface UserInfo {
username: string;
profiles: UserProfiles[];
}
export const useAuthStore = defineStore("auth", () => {
const userInfo = ref<UserInfo | null>(null);
const savedPath = ref<string | null>(null);
savedPath.value = window.localStorage.getItem("savedPath");
async function updateUserInfo() {
try {
userInfo.value = await get<UserInfo>("/api/user");
} catch (response: Response | TypeError | any) {
if (response instanceof Response && response.status == 401) {
userInfo.value = null;
return true;
}
if (response instanceof TypeError) return Promise.reject(response);
}
return true;
}
let ready = updateUserInfo();
async function login(username: string, password: string) {
const response = await send("/api/user/login", {
username,
password,
});
ready = updateUserInfo();
await ready;
return response;
}
async function logout() {
await send("/api/user/logout");
ready = updateUserInfo();
await ready;
}
async function savePath(value: string) {
savedPath.value = value;
if (savedPath.value)
window.localStorage.setItem("savedPath", savedPath.value);
else window.localStorage.removeItem("savedPath");
}
return { userInfo, login, logout, ready, savedPath, savePath };
});
export default useAuthStore;

72
Web/src/utils.ts Normal file
View File

@ -0,0 +1,72 @@
export function toHex(buffer: ArrayBuffer | Uint8Array) {
const view = new Uint8Array(buffer);
const out: string[] = [];
view.forEach((n) => out.push(n.toString(16).padStart(2, "0")));
return out.join("");
}
export function fromHex(input: string) {
const numbers = input
.match(/[0-9a-zA-Z]{2,2}/g)
?.map((v) => parseInt(v, 16));
if (!numbers) throw new Error("Input cannot be parsed as hex string");
return new Uint8Array(numbers);
}
export async function get<T>(
url: string,
headers?: Record<string, string>
): Promise<T> {
return send(url, undefined, "GET", headers);
}
export async function send<Treceived, Tsent = any | undefined>(
url: string,
data?: Tsent,
method: "POST" | "PUT" | "PATCH" | "DELETE" | "GET" = "POST",
headers?: Record<string, string>
): Promise<Treceived> {
if (!url.startsWith("htt") && window.location.port == "5173")
url =
"http://localhost:3000" +
(url.startsWith("/") ? "" : window.location.pathname + "/") +
url;
if (fetch) {
return fetch(url, {
method: method,
headers: data
? {
"Content-Type": "application/json",
...headers,
}
: headers,
body: data ? JSON.stringify(data) : undefined,
}).then((response) => {
if (!response.ok) return Promise.reject(response);
return response.json();
});
}
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
for (const header in headers ?? {}) {
if (
!headers ||
!Object.prototype.hasOwnProperty.call(headers, header)
)
continue;
const value = headers[header];
xhr.setRequestHeader(header, value);
}
xhr.open(method, url, true);
xhr.addEventListener("load", function (e) {
resolve(JSON.parse(this.response));
});
xhr.addEventListener("error", function (e) {
reject(e);
});
if (data) xhr.send(JSON.stringify(data));
else xhr.send();
});
}

View File

@ -0,0 +1,131 @@
<template>
<main>
<h2>
IndieAuth login request for: {{ state.request?.clientId }} ({{
state.request?.responseType
}})
</h2>
<form @submit.prevent="sendAuthorize">
<div>
<div v-if="state.request?.scope">
Scopes requested:
<ul>
<li v-for="scope in state.request.scope.split(' ')">
{{ scope }}
</li>
</ul>
</div>
<div v-else>No scopes requested</div>
</div>
<label>
<span>Profile ID</span>
<input
class="border border-slate-500"
placeholder="Profile ID"
v-model="state.profileId"
/>
</label>
<label v-if="!hasPkce">
<input v-model="state.ignoreMissingPkce" type="checkbox" />
Ignore missing PKCE
</label>
<div v-if="state.errorText">{{ state.errorText }}</div>
<button
type="submit"
:disabled="!hasPkce && !state.ignoreMissingPkce"
>
Authorize
</button>
<a v-if="state.redirectUri" :href="state.redirectUri">
Client redirect page
</a>
</form>
</main>
</template>
<script setup lang="ts">
import { send } from "@/utils";
import { computed } from "@vue/reactivity";
import { onMounted, ref } from "vue";
const state = ref({
profileId: "",
errorText: "",
request: null as AuthRequest | null,
redirectUri: "",
ignoreMissingPkce: false,
});
interface AuthRequest {
responseType: string;
clientId: string;
redirectUri: string;
state?: string;
codeChallenge?: string;
codeChallengeMethod?: string;
scope?: string;
me?: string;
}
onMounted(() => {
const paramData = {} as { [idx: string]: string };
for (const iterator of new URLSearchParams(window.location.search)) {
paramData[
iterator[0].replace(/_(\w)/g, (_, letter) => letter.toUpperCase())
] = iterator[1];
}
state.value.request = paramData as any as AuthRequest;
});
const hasPkce = computed(
() =>
!!state.value.request?.codeChallenge &&
!!state.value.request.codeChallengeMethod
);
async function sendAuthorize() {
if (!hasPkce.value && !state.value.ignoreMissingPkce) return;
const request = state.value.request;
if (!request) return;
if (
!request.clientId ||
!request.redirectUri ||
request.responseType != "code"
) {
state.value.errorText = "Invalid IndieAuth request";
return;
}
try {
const result = await send<{ redirectUri: string }>(
"/api/indieauth/request",
{
...request,
profileId: +state.value.profileId,
}
);
state.value.redirectUri = result.redirectUri;
console.log(result);
} catch (e) {
console.log(e);
let text = e as string;
if (e instanceof Response) {
if (e.bodyUsed) {
// Shouldn't happen but, just in case
text = `${e.status} - ${e.statusText} - "Unable to get error, body already used"`;
} else {
const responseText = await e.text();
if (responseText.trim() && responseText.startsWith("{"))
text =
JSON.parse(responseText)?.error ||
`${e.status} - ${e.statusText} - No detailed error information given`;
else text = `${e.status} - ${e.statusText} - No body present`;
}
}
state.value.errorText = "Error while authorizing the request:\n" + text;
}
}
</script>

View File

@ -0,0 +1,18 @@
<script setup lang="ts">
import TailwindButton from "@/components/TailwindButton.vue";
</script>
<template>
<main class="flex flex-col w-80">
<h2 class="mb-2 text-center text-xl">Home</h2>
<div>TODO</div>
<tailwind-button
title="This will erase all local data!"
to="logout"
class="mx-2"
custom-class="bg-red-800/20 border-red-800/10 text-zinc-400 hover:bg-red-800/60 hover:border-red-800/50"
>
Logout
</tailwind-button>
</main>
</template>

135
Web/src/views/LoginView.vue Normal file
View File

@ -0,0 +1,135 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import useAuthStore from "@/stores/auth";
import { computed } from "@vue/reactivity";
import { ref } from "vue";
import Collapsible from "../components/Collapsible.vue";
import TailwindButton from "../components/TailwindButton.vue";
const router = useRouter();
const auth = useAuthStore();
const email = ref("");
const password = ref("");
const loggingIn = ref(false);
const error = ref("");
const disableButton = computed(
() => loggingIn.value || !email.value || !password.value
);
async function login() {
loggingIn.value = true;
try {
const loginInfo = await auth.login(email.value, password.value);
console.log("Logged in as user", loginInfo, auth.userInfo);
router.push("/");
} catch (response) {
if (response instanceof Error) {
console.error(response);
loggingIn.value = false;
error.value = response.message;
return;
}
if (response instanceof Response) {
const buffer = await response.arrayBuffer();
await new Promise<void>((resolve) =>
setTimeout(() => resolve(), 400)
);
loggingIn.value = false;
if (buffer.byteLength > 0) {
const json = JSON.parse(new TextDecoder().decode(buffer));
error.value = json.error ?? response.statusText;
} else {
error.value = `Error ${response.status}: ${response.statusText}`;
}
} else {
error.value = `Unknown error: ${response}`;
}
}
}
</script>
<template>
<main>
<form
id="login-form"
@submit.prevent="login"
class="flex flex-col w-80"
>
<h2 class="mb-2 text-center text-2xl">Lapis Auth</h2>
<hr class="mt-1 mb-2 border-zinc-800" />
<label class="flex flex-col mb-2">
<span class="block mb-1 px-1" for="email"> Email </span>
<input
required
v-model="email"
id="email"
class="bg-slate-700 px-2 mx-2 py-1 rounded border border-slate-600 caret-slate-400"
type="email"
autocomplete="email"
form="login-form"
/>
</label>
<label class="flex flex-col mb-2">
<span class="block mb-1 px-1" for="password"> Password </span>
<input
required
v-model="password"
id="password"
class="bg-slate-700 px-2 mx-2 py-1 rounded border border-slate-600 caret-slate-400"
type="password"
autocomplete="current-password"
form="login-form"
/>
</label>
<router-link
class="mx-1 mb-2 py-1 text-center rounded text-zinc-500"
to="register"
>
Forgot password
</router-link>
<Collapsible class="" v-model="error" />
<hr class="mt-2 mb-3 border-zinc-800" />
<TailwindButton
type="submit"
:disabled="disableButton"
class="mx-3 mb-3"
>
<template v-if="!loggingIn">Login</template>
<div v-else>
<span
class="relative animate-pulse inline-flex rounded-full h-2 mx-1 w-2 bg-sky-500"
></span>
<span
class="relative animate-pulse pulse-2 inline-flex rounded-full h-2 mx-1 w-2 bg-sky-500"
></span>
<span
class="relative animate-pulse pulse-3 inline-flex rounded-full h-2 mx-1 w-2 bg-sky-500"
></span>
</div>
</TailwindButton>
<TailwindButton
type="button"
custom-class="bg-stone-900 border-stone-900"
class="mx-3"
to="register"
>
Register
</TailwindButton>
</form>
</main>
</template>
<style>
.pulse-2 {
animation-delay: calc(2s / 6) !important;
}
.pulse-3 {
animation-delay: calc(2s / 6 * 2) !important;
}
</style>

View File

@ -0,0 +1,10 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import useAuthStore from "@/stores/auth";
const router = useRouter();
const auth = useAuthStore();
auth.logout().then(() => router.push("/login"));
</script>
<template><main>Logging out</main></template>

View File

@ -0,0 +1,9 @@
<script setup lang="ts">
import TailwindButton from "../components/TailwindButton.vue";
</script>
<template>
<div>
<h2 class="mb-2 text-center text-2xl">Page not found</h2>
</div>
</template>

View File

@ -0,0 +1,10 @@
<script setup lang="ts">
import TailwindButton from "../components/TailwindButton.vue";
</script>
<template>
<div>
<h2 class="mb-2 text-center text-2xl">Offline</h2>
<tailwind-button>Refresh</tailwind-button>
</div>
</template>

View File

@ -0,0 +1,5 @@
<script setup lang="ts"></script>
<template>
<main>Registration page</main>
</template>

9
Web/tailwind.config.js Normal file
View File

@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
theme: {
extend: {
},
},
plugins: [],
};

8
Web/tsconfig.config.json Normal file
View File

@ -0,0 +1,8 @@
{
"extends": "@vue/tsconfig/tsconfig.node.json",
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*"],
"compilerOptions": {
"composite": true,
"types": ["node"]
}
}

21
Web/tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"extends": "@vue/tsconfig/tsconfig.web.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"lib": [
"ES2017",
"DOM",
"DOM.Iterable"
]
},
"references": [
{
"path": "./tsconfig.config.json"
}
]
}

31
Web/vite.config.ts Normal file
View File

@ -0,0 +1,31 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import fs from "fs";
import path from "path";
const commitHash = fs.existsSync(path.join("..", ".git"))
? require("child_process")
.execSync(
"git describe --long --always --dirty --exclude=* --abbrev=8"
)
.toString()
: process.env.VCS_DESCRIBE || null;
// https://vitejs.dev/config/
export default defineConfig({
build: {
target: ["es2017"],
},
define: {
__APP_VERSION__: JSON.stringify(process.env.npm_package_version),
__COMMIT_HASH__: commitHash ? JSON.stringify(commitHash) : null,
},
plugins: [vue()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
});

13
appsettings.json Normal file
View File

@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"LapisContext": "Data Source=lapis.db"
},
"Lapis": {}
}

41
lapis.csproj Normal file
View File

@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Lunar.Exchange.Lapis</RootNamespace>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>
<PropertyGroup>
<VersionPrefix>0.1.0</VersionPrefix>
</PropertyGroup>
<Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation">
<Exec
Command="which git > /dev/null 2>&amp;1 &amp;&amp; git describe --long --always --dirty --exclude=* --abbrev=8 2>/dev/null || echo -n $VCS_DESCRIBE"
EchoOff="True"
ConsoleToMSBuild="True"
IgnoreExitCode="False"
>
<Output PropertyName="SourceRevisionId" TaskParameter="ConsoleOutput"/>
</Exec>
</Target>
<ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.Sqlite" Version="6.0.2" />
<PackageReference Include="DotNet.ReproducibleBuilds" Version="1.1.1" PrivateAssets="All"/>
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="6.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.4" />
</ItemGroup>
</Project>