Compare commits

...

2 commits

Author SHA1 Message Date
sam
7791c91960
feat(backend): initial /api/v1/users endpoint 2024-12-25 11:19:50 -05:00
sam
5e7df2e074
feat(frontend): add footer 2024-12-25 11:04:20 -05:00
23 changed files with 484 additions and 7 deletions

View file

@ -22,6 +22,7 @@ using Foxnouns.Backend.Services;
using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using XidNet;
namespace Foxnouns.Backend.Controllers;
@ -64,6 +65,7 @@ public class FlagsController(
var flag = new PrideFlag
{
Id = snowflakeGenerator.GenerateSnowflake(),
LegacyId = Xid.NewXid().ToString(),
UserId = CurrentUser!.Id,
Name = req.Name,
Description = req.Description,

View file

@ -26,6 +26,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using NodaTime;
using XidNet;
namespace Foxnouns.Backend.Controllers;
@ -101,6 +102,7 @@ public class MembersController(
var member = new Member
{
Id = snowflakeGenerator.GenerateSnowflake(),
LegacyId = Xid.NewXid().ToString(),
User = CurrentUser!,
Name = req.Name,
DisplayName = req.DisplayName,

View file

@ -222,7 +222,7 @@ public class UsersController(
.CustomPreferences.Where(x => req.Any(r => r.Id == x.Key))
.ToDictionary();
foreach (CustomPreferenceUpdateRequest? r in req)
foreach (CustomPreferenceUpdateRequest r in req)
{
if (r.Id != null && preferences.ContainsKey(r.Id.Value))
{
@ -233,6 +233,7 @@ public class UsersController(
Muted = r.Muted,
Size = r.Size,
Tooltip = r.Tooltip,
LegacyId = preferences[r.Id.Value].LegacyId,
};
}
else
@ -244,6 +245,7 @@ public class UsersController(
Muted = r.Muted,
Size = r.Size,
Tooltip = r.Tooltip,
LegacyId = Guid.NewGuid(),
};
}
}

View file

@ -0,0 +1,16 @@
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Services.V1;
using Microsoft.AspNetCore.Mvc;
namespace Foxnouns.Backend.Controllers.V1;
[Route("/api/v1/users")]
public class UsersV1Controller(UsersV1Service usersV1Service) : ApiControllerBase
{
[HttpGet("{userRef}")]
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
{
User user = await usersV1Service.ResolveUserAsync(userRef, CurrentToken, ct);
return Ok(await usersV1Service.RenderUserAsync(user));
}
}

View file

@ -139,6 +139,26 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
modelBuilder
.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeMemberSid))!)
.HasName("find_free_member_sid");
// Indexes for legacy IDs for APIv1
modelBuilder.Entity<User>().HasIndex(u => u.LegacyId).IsUnique();
modelBuilder.Entity<Member>().HasIndex(m => m.LegacyId).IsUnique();
modelBuilder.Entity<PrideFlag>().HasIndex(f => f.LegacyId).IsUnique();
// a UUID is not an xid, but this should always be set by the application anyway.
// we're just setting it here to shut EFCore up because squashing migrations is for nerds
modelBuilder
.Entity<User>()
.Property(u => u.LegacyId)
.HasDefaultValueSql("gen_random_uuid()");
modelBuilder
.Entity<Member>()
.Property(m => m.LegacyId)
.HasDefaultValueSql("gen_random_uuid()");
modelBuilder
.Entity<PrideFlag>()
.Property(f => f.LegacyId)
.HasDefaultValueSql("gen_random_uuid()");
}
/// <summary>

View file

@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241225155818_AddLegacyIds")]
public partial class AddLegacyIds : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "legacy_id",
table: "users",
type: "text",
nullable: false,
defaultValueSql: "gen_random_uuid()"
);
migrationBuilder.AddColumn<string>(
name: "legacy_id",
table: "pride_flags",
type: "text",
nullable: false,
defaultValueSql: "gen_random_uuid()"
);
migrationBuilder.AddColumn<string>(
name: "legacy_id",
table: "members",
type: "text",
nullable: false,
defaultValueSql: "gen_random_uuid()"
);
migrationBuilder.CreateIndex(
name: "ix_users_legacy_id",
table: "users",
column: "legacy_id",
unique: true
);
migrationBuilder.CreateIndex(
name: "ix_pride_flags_legacy_id",
table: "pride_flags",
column: "legacy_id",
unique: true
);
migrationBuilder.CreateIndex(
name: "ix_members_legacy_id",
table: "members",
column: "legacy_id",
unique: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(name: "ix_users_legacy_id", table: "users");
migrationBuilder.DropIndex(name: "ix_pride_flags_legacy_id", table: "pride_flags");
migrationBuilder.DropIndex(name: "ix_members_legacy_id", table: "members");
migrationBuilder.DropColumn(name: "legacy_id", table: "users");
migrationBuilder.DropColumn(name: "legacy_id", table: "pride_flags");
migrationBuilder.DropColumn(name: "legacy_id", table: "members");
}
}
}

View file

@ -254,6 +254,13 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("jsonb")
.HasColumnName("fields");
b.Property<string>("LegacyId")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("legacy_id")
.HasDefaultValueSql("gen_random_uuid()");
b.PrimitiveCollection<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
@ -292,6 +299,10 @@ namespace Foxnouns.Backend.Database.Migrations
b.HasKey("Id")
.HasName("pk_members");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_members_legacy_id");
b.HasIndex("Sid")
.IsUnique()
.HasDatabaseName("ix_members_sid");
@ -386,6 +397,13 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("text")
.HasColumnName("hash");
b.Property<string>("LegacyId")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("legacy_id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
@ -398,6 +416,10 @@ namespace Foxnouns.Backend.Database.Migrations
b.HasKey("Id")
.HasName("pk_pride_flags");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_pride_flags_legacy_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_pride_flags_user_id");
@ -582,6 +604,13 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("last_sid_reroll");
b.Property<string>("LegacyId")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("legacy_id")
.HasDefaultValueSql("gen_random_uuid()");
b.PrimitiveCollection<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
@ -637,6 +666,10 @@ namespace Foxnouns.Backend.Database.Migrations
b.HasKey("Id")
.HasName("pk_users");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_users_legacy_id");
b.HasIndex("Sid")
.IsUnique()
.HasDatabaseName("ix_users_sid");

View file

@ -18,6 +18,7 @@ public class Member : BaseModel
{
public required string Name { get; set; }
public string Sid { get; set; } = string.Empty;
public required string LegacyId { get; init; }
public string? DisplayName { get; set; }
public string? Bio { get; set; }
public string? Avatar { get; set; }

View file

@ -17,6 +17,7 @@ namespace Foxnouns.Backend.Database.Models;
public class PrideFlag : BaseModel
{
public required Snowflake UserId { get; init; }
public required string LegacyId { get; init; }
// A null hash means the flag hasn't been processed yet.
public string? Hash { get; set; }

View file

@ -25,6 +25,7 @@ public class User : BaseModel
{
public required string Username { get; set; }
public string Sid { get; set; } = string.Empty;
public required string LegacyId { get; init; }
public string? DisplayName { get; set; }
public string? Bio { get; set; }
public string? MemberTitle { get; set; }
@ -69,6 +70,8 @@ public class User : BaseModel
// This type is generally serialized directly, so the converter is applied here.
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
public PreferenceSize Size { get; set; }
public Guid LegacyId { get; init; } = Guid.NewGuid();
}
public static readonly Duration DeleteAfter = Duration.FromDays(30);

View file

@ -36,7 +36,7 @@ public record UserResponse(
IEnumerable<FieldEntry> Names,
IEnumerable<Pronoun> Pronouns,
IEnumerable<Field> Fields,
Dictionary<Snowflake, User.CustomPreference> CustomPreferences,
Dictionary<Snowflake, CustomPreferenceResponse> CustomPreferences,
IEnumerable<PrideFlagResponse> Flags,
int? UtcOffset,
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] UserRole Role,
@ -52,6 +52,14 @@ public record UserResponse(
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Deleted
);
public record CustomPreferenceResponse(
string Icon,
string Tooltip,
bool Muted,
bool Favourite,
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] PreferenceSize Size
);
public record AuthMethodResponse(
Snowflake Id,
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type,

View file

@ -0,0 +1,77 @@
// ReSharper disable NotAccessedPositionalProperty.Global
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Services.V1;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Serialization;
namespace Foxnouns.Backend.Dto.V1;
public record UserResponse(
string Id,
Snowflake IdNew,
string Sid,
string Name,
string? DisplayName,
string? Bio,
string? MemberTitle,
string? Avatar,
string[] Links,
FieldEntry[] Names,
PronounEntry[] Pronouns,
ProfileField[] Fields,
int? UtcOffset,
Dictionary<Guid, CustomPreference> CustomPreferences
);
public record CustomPreference(
string Icon,
string Tooltip,
[property: JsonConverter(typeof(StringEnumConverter), typeof(SnakeCaseNamingStrategy))]
PreferenceSize Size,
bool Muted,
bool Favourite
);
public record ProfileField(string Name, FieldEntry[] Entries)
{
public static ProfileField FromField(
Field field,
Dictionary<Snowflake, User.CustomPreference> customPreferences
) => new(field.Name, FieldEntry.FromEntries(field.Entries, customPreferences));
public static ProfileField[] FromFields(
IEnumerable<Field> fields,
Dictionary<Snowflake, User.CustomPreference> customPreferences
) => fields.Select(f => FromField(f, customPreferences)).ToArray();
}
public record FieldEntry(string Value, string Status)
{
public static FieldEntry[] FromEntries(
IEnumerable<Foxnouns.Backend.Database.Models.FieldEntry> entries,
Dictionary<Snowflake, User.CustomPreference> customPreferences
) =>
entries
.Select(e => new FieldEntry(
e.Value,
V1Utils.TranslateStatus(e.Status, customPreferences)
))
.ToArray();
}
public record PronounEntry(string Pronouns, string? DisplayText, string Status)
{
public static PronounEntry[] FromPronouns(
IEnumerable<Pronoun> pronouns,
Dictionary<Snowflake, User.CustomPreference> customPreferences
) =>
pronouns
.Select(p => new PronounEntry(
p.Value,
p.DisplayText,
V1Utils.TranslateStatus(p.Status, customPreferences)
))
.ToArray();
}

View file

@ -19,6 +19,7 @@ using Foxnouns.Backend.Jobs;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Foxnouns.Backend.Services.Auth;
using Foxnouns.Backend.Services.V1;
using Microsoft.EntityFrameworkCore;
using Minio;
using NodaTime;
@ -127,7 +128,9 @@ public static class WebApplicationExtensions
.AddTransient<MemberAvatarUpdateInvocable>()
.AddTransient<UserAvatarUpdateInvocable>()
.AddTransient<CreateFlagInvocable>()
.AddTransient<CreateDataExportInvocable>();
.AddTransient<CreateDataExportInvocable>()
// Legacy services
.AddScoped<UsersV1Service>();
if (!config.Logging.EnableMetrics)
services.AddHostedService<BackgroundMetricsCollectionService>();

View file

@ -44,6 +44,7 @@
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.6"/>
<PackageReference Include="System.Text.Json" Version="9.0.0"/>
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1"/>
<PackageReference Include="Yort.Xid.Net" Version="2.0.1"/>
</ItemGroup>
<Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation">

View file

@ -20,6 +20,7 @@ using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using XidNet;
namespace Foxnouns.Backend.Services.Auth;
@ -70,6 +71,7 @@ public class AuthService(
},
LastActive = clock.GetCurrentInstant(),
Sid = null!,
LegacyId = Xid.NewXid().ToString(),
};
db.Add(user);
@ -116,6 +118,7 @@ public class AuthService(
},
LastActive = clock.GetCurrentInstant(),
Sid = null!,
LegacyId = Xid.NewXid().ToString(),
};
db.Add(user);

View file

@ -103,7 +103,8 @@ public class UserRendererService(
user.Names,
user.Pronouns,
user.Fields,
user.CustomPreferences,
user.CustomPreferences.Select(x => (x.Key, RenderCustomPreference(x.Value)))
.ToDictionary(),
flags.Select(f => RenderPrideFlag(f.PrideFlag)),
utcOffset,
user.Role,
@ -130,6 +131,14 @@ public class UserRendererService(
: a.RemoteUsername
);
public static CustomPreferenceResponse RenderCustomPreference(User.CustomPreference pref) =>
new(pref.Icon, pref.Tooltip, pref.Muted, pref.Favourite, pref.Size);
public static Dictionary<Snowflake, CustomPreferenceResponse> RenderCustomPreferences(
User user
) =>
user.CustomPreferences.Select(x => (x.Key, RenderCustomPreference(x.Value))).ToDictionary();
public PartialUser RenderPartialUser(User user) =>
new(
user.Id,

View file

@ -0,0 +1,92 @@
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto.V1;
using Microsoft.EntityFrameworkCore;
using FieldEntry = Foxnouns.Backend.Dto.V1.FieldEntry;
namespace Foxnouns.Backend.Services.V1;
public class UsersV1Service(DatabaseContext db)
{
public async Task<User> ResolveUserAsync(
string userRef,
Token? token,
CancellationToken ct = default
)
{
if (userRef == "@me")
{
if (token == null)
{
throw new ApiError.Unauthorized(
"This endpoint requires an authenticated user.",
ErrorCode.AuthenticationRequired
);
}
return await db.Users.FirstAsync(u => u.Id == token.UserId, ct);
}
User? user;
if (Snowflake.TryParse(userRef, out Snowflake? sf))
{
user = await db.Users.FirstOrDefaultAsync(u => u.Id == sf && !u.Deleted, ct);
if (user != null)
return user;
}
user = await db.Users.FirstOrDefaultAsync(u => u.LegacyId == userRef && !u.Deleted, ct);
if (user != null)
return user;
user = await db.Users.FirstOrDefaultAsync(u => u.Username == userRef && !u.Deleted, ct);
if (user != null)
return user;
throw new ApiError.NotFound(
"No user with that ID or username found.",
ErrorCode.UserNotFound
);
}
public async Task<UserResponse> RenderUserAsync(User user)
{
int? utcOffset = null;
if (
user.Timezone != null
&& TimeZoneInfo.TryFindSystemTimeZoneById(user.Timezone, out TimeZoneInfo? tz)
)
{
utcOffset = (int)tz.GetUtcOffset(DateTimeOffset.UtcNow).TotalSeconds;
}
return new UserResponse(
user.LegacyId,
user.Id,
user.Sid,
user.Username,
user.DisplayName,
user.Bio,
user.MemberTitle,
user.Avatar,
user.Links,
FieldEntry.FromEntries(user.Names, user.CustomPreferences),
PronounEntry.FromPronouns(user.Pronouns, user.CustomPreferences),
ProfileField.FromFields(user.Fields, user.CustomPreferences),
utcOffset,
user.CustomPreferences.Select(x =>
(
x.Value.LegacyId,
new CustomPreference(
x.Value.Icon,
x.Value.Tooltip,
x.Value.Size,
x.Value.Muted,
x.Value.Favourite
)
)
)
.ToDictionary()
);
}
}

View file

@ -0,0 +1,20 @@
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
namespace Foxnouns.Backend.Services.V1;
public static class V1Utils
{
public static string TranslateStatus(
string status,
Dictionary<Snowflake, User.CustomPreference> customPreferences
)
{
if (!Snowflake.TryParse(status, out Snowflake? sf))
return status;
return customPreferences.TryGetValue(sf.Value, out User.CustomPreference? cf)
? cf.LegacyId.ToString()
: "unknown";
}
}

View file

@ -293,6 +293,12 @@
"System.Runtime": "4.3.1"
}
},
"Yort.Xid.Net": {
"type": "Direct",
"requested": "[2.0.1, )",
"resolved": "2.0.1",
"contentHash": "+3sNX7/RKSKheVuMz9jtWLazD+R4PXpx8va2d9SdDgvKOhETbEb0VYis8K/fD1qm/qOQT57LadToSpzReGMZlw=="
},
"BouncyCastle.Cryptography": {
"type": "Transitive",
"resolved": "2.5.0",

View file

@ -39,6 +39,7 @@ public class UserMigrator(
_user = new User
{
Id = goUser.SnowflakeId,
LegacyId = goUser.Id,
Username = goUser.Username,
DisplayName = goUser.DisplayName,
Bio = goUser.Bio,
@ -139,6 +140,7 @@ public class UserMigrator(
new PrideFlag
{
Id = flag.SnowflakeId,
LegacyId = flag.Id,
UserId = _user!.Id,
Hash = flag.Hash,
Name = flag.Name,
@ -190,6 +192,7 @@ public class UserMigrator(
UserId = _user!.Id,
Name = goMember.Name,
Sid = goMember.Sid,
LegacyId = goMember.Id,
DisplayName = goMember.DisplayName,
Bio = goMember.Bio,
Avatar = goMember.Avatar,
@ -235,6 +238,7 @@ public class UserMigrator(
"small" => PreferenceSize.Small,
_ => PreferenceSize.Normal,
},
LegacyId = new Guid(id),
};
}

View file

@ -64,3 +64,11 @@
max-width: 200px;
border-radius: 3px;
}
.big-footer {
@media (prefers-color-scheme: dark) {
background-color: bootstrap.shade-color(bootstrap.$dark, 20%);
}
background-color: bootstrap.shade-color(bootstrap.$light, 5%);
}

View file

@ -0,0 +1,83 @@
<script lang="ts">
import type { Meta } from "$api/models";
import Git from "svelte-bootstrap-icons/lib/Git.svelte";
import Reception4 from "svelte-bootstrap-icons/lib/Reception4.svelte";
import Newspaper from "svelte-bootstrap-icons/lib/Newspaper.svelte";
import CardText from "svelte-bootstrap-icons/lib/CardText.svelte";
import Shield from "svelte-bootstrap-icons/lib/Shield.svelte";
import Envelope from "svelte-bootstrap-icons/lib/Envelope.svelte";
import CashCoin from "svelte-bootstrap-icons/lib/CashCoin.svelte";
import Logo from "./Logo.svelte";
type Props = { meta: Meta };
let { meta }: Props = $props();
</script>
<footer class="big-footer mt-3 pt-3 pb-1 px-5">
<div class="d-flex flex-column flex-md-row mb-2">
<div class="align-start flex-grow-1">
<Logo />
<ul class="mt-2 list-unstyled">
<li><strong>Version</strong> {meta.version}</li>
</ul>
</div>
<div class="align-end">
<ul class="list-unstyled">
<li>{meta.users.total} <strong>users</strong></li>
<li>{meta.members} <strong>members</strong></li>
</ul>
</div>
</div>
<ul class="list-inline">
<a
class="list-inline-item link-underline link-underline-opacity-0"
target="_blank"
href={meta.repository}
>
<li class="list-inline-item">
<Git />
Source code
</li>
</a>
<a
class="list-inline-item link-underline link-underline-opacity-0"
target="_blank"
href="https://status.pronouns.cc"
>
<li class="list-inline-item">
<Reception4 />
Status
</li>
</a>
<a class="list-inline-item link-underline link-underline-opacity-0" href="/page/about">
<li class="list-inline-item">
<Envelope />
About and contact
</li>
</a>
<a class="list-inline-item link-underline link-underline-opacity-0" href="/page/tos">
<li class="list-inline-item">
<CardText />
Terms of service
</li>
</a>
<a class="list-inline-item link-underline link-underline-opacity-0" href="/page/privacy">
<li class="list-inline-item">
<Shield />
Privacy policy
</li>
</a>
<a class="list-inline-item link-underline link-underline-opacity-0" href="/page/changelog">
<li class="list-inline-item">
<Newspaper />
Changelog
</li>
</a>
<a class="list-inline-item link-underline link-underline-opacity-0" href="/page/donate">
<li class="list-inline-item">
<CashCoin />
Donate
</li>
</a>
</ul>
</footer>

View file

@ -3,11 +3,16 @@
import "../app.scss";
import type { LayoutData } from "./$types";
import Navbar from "$components/Navbar.svelte";
import Footer from "$components/Footer.svelte";
type Props = { children: Snippet; data: LayoutData };
let { children, data }: Props = $props();
</script>
<Navbar user={data.meUser} meta={data.meta} />
{@render children?.()}
<div class="d-flex flex-column min-vh-100">
<div class="flex-grow-1">
<Navbar user={data.meUser} meta={data.meta} />
{@render children?.()}
</div>
<Footer meta={data.meta} />
</div>