diff --git a/.gitignore b/.gitignore
index 9037fa0..b1e845f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,6 +14,8 @@ docker/proxy-config.json
docker/frontend.env
Foxnouns.DataMigrator/apps.json
+migration-tools/avatar-proxy/config.json
+migration-tools/avatar-migrator/.env
out/
build/
diff --git a/Foxnouns.Backend/Database/Migrations/20250410192220_AddAvatarMigrations.Designer.cs b/Foxnouns.Backend/Database/Migrations/20250410192220_AddAvatarMigrations.Designer.cs
new file mode 100644
index 0000000..cb9377d
--- /dev/null
+++ b/Foxnouns.Backend/Database/Migrations/20250410192220_AddAvatarMigrations.Designer.cs
@@ -0,0 +1,923 @@
+//
+using System.Collections.Generic;
+using Foxnouns.Backend.Database;
+using Foxnouns.Backend.Database.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using NodaTime;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Foxnouns.Backend.Database.Migrations
+{
+ [DbContext(typeof(DatabaseContext))]
+ [Migration("20250410192220_AddAvatarMigrations")]
+ partial class AddAvatarMigrations
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.2")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("ClientId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("client_id");
+
+ b.Property("ClientSecret")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("client_secret");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.PrimitiveCollection("RedirectUris")
+ .IsRequired()
+ .HasColumnType("text[]")
+ .HasColumnName("redirect_uris");
+
+ b.PrimitiveCollection("Scopes")
+ .IsRequired()
+ .HasColumnType("text[]")
+ .HasColumnName("scopes");
+
+ b.HasKey("Id")
+ .HasName("pk_applications");
+
+ b.ToTable("applications", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.PrimitiveCollection("ClearedFields")
+ .HasColumnType("text[]")
+ .HasColumnName("cleared_fields");
+
+ b.Property("ModeratorId")
+ .HasColumnType("bigint")
+ .HasColumnName("moderator_id");
+
+ b.Property("ModeratorUsername")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("moderator_username");
+
+ b.Property("Reason")
+ .HasColumnType("text")
+ .HasColumnName("reason");
+
+ b.Property("ReportId")
+ .HasColumnType("bigint")
+ .HasColumnName("report_id");
+
+ b.Property("TargetMemberId")
+ .HasColumnType("bigint")
+ .HasColumnName("target_member_id");
+
+ b.Property("TargetMemberName")
+ .HasColumnType("text")
+ .HasColumnName("target_member_name");
+
+ b.Property("TargetUserId")
+ .HasColumnType("bigint")
+ .HasColumnName("target_user_id");
+
+ b.Property("TargetUsername")
+ .HasColumnType("text")
+ .HasColumnName("target_username");
+
+ b.Property("Type")
+ .HasColumnType("integer")
+ .HasColumnName("type");
+
+ b.HasKey("Id")
+ .HasName("pk_audit_log");
+
+ b.HasIndex("ReportId")
+ .IsUnique()
+ .HasDatabaseName("ix_audit_log_report_id");
+
+ b.ToTable("audit_log", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("AuthType")
+ .HasColumnType("integer")
+ .HasColumnName("auth_type");
+
+ b.Property("FediverseApplicationId")
+ .HasColumnType("bigint")
+ .HasColumnName("fediverse_application_id");
+
+ b.Property("RemoteId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("remote_id");
+
+ b.Property("RemoteUsername")
+ .HasColumnType("text")
+ .HasColumnName("remote_username");
+
+ b.Property("UserId")
+ .HasColumnType("bigint")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_auth_methods");
+
+ b.HasIndex("FediverseApplicationId")
+ .HasDatabaseName("ix_auth_methods_fediverse_application_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_auth_methods_user_id");
+
+ b.HasIndex("AuthType", "RemoteId")
+ .IsUnique()
+ .HasDatabaseName("ix_auth_methods_auth_type_remote_id")
+ .HasFilter("fediverse_application_id IS NULL");
+
+ b.HasIndex("AuthType", "RemoteId", "FediverseApplicationId")
+ .IsUnique()
+ .HasDatabaseName("ix_auth_methods_auth_type_remote_id_fediverse_application_id")
+ .HasFilter("fediverse_application_id IS NOT NULL");
+
+ b.ToTable("auth_methods", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("Filename")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("filename");
+
+ b.Property("UserId")
+ .HasColumnType("bigint")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_data_exports");
+
+ b.HasIndex("Filename")
+ .IsUnique()
+ .HasDatabaseName("ix_data_exports_filename");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_data_exports_user_id");
+
+ b.ToTable("data_exports", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.FediverseApplication", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("ClientId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("client_id");
+
+ b.Property("ClientSecret")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("client_secret");
+
+ b.Property("Domain")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("domain");
+
+ b.Property("ForceRefresh")
+ .HasColumnType("boolean")
+ .HasColumnName("force_refresh");
+
+ b.Property("InstanceType")
+ .HasColumnType("integer")
+ .HasColumnName("instance_type");
+
+ b.HasKey("Id")
+ .HasName("pk_fediverse_applications");
+
+ b.ToTable("fediverse_applications", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("Avatar")
+ .HasColumnType("text")
+ .HasColumnName("avatar");
+
+ b.Property("AvatarMigrated")
+ .HasColumnType("boolean")
+ .HasColumnName("avatar_migrated");
+
+ b.Property("Bio")
+ .HasColumnType("text")
+ .HasColumnName("bio");
+
+ b.Property("DisplayName")
+ .HasColumnType("text")
+ .HasColumnName("display_name");
+
+ b.Property>("Fields")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("fields");
+
+ b.Property("LegacyId")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasColumnName("legacy_id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.PrimitiveCollection("Links")
+ .IsRequired()
+ .HasColumnType("text[]")
+ .HasColumnName("links");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property>("Names")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("names");
+
+ b.Property>("Pronouns")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("pronouns");
+
+ b.Property("Sid")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasColumnName("sid")
+ .HasDefaultValueSql("find_free_member_sid()");
+
+ b.Property("Unlisted")
+ .HasColumnType("boolean")
+ .HasColumnName("unlisted");
+
+ b.Property("UserId")
+ .HasColumnType("bigint")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_members");
+
+ b.HasIndex("LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_members_legacy_id");
+
+ b.HasIndex("Sid")
+ .IsUnique()
+ .HasDatabaseName("ix_members_sid");
+
+ b.HasIndex("UserId", "Name")
+ .IsUnique()
+ .HasDatabaseName("ix_members_user_id_name");
+
+ b.ToTable("members", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.MemberFlag", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("MemberId")
+ .HasColumnType("bigint")
+ .HasColumnName("member_id");
+
+ b.Property("PrideFlagId")
+ .HasColumnType("bigint")
+ .HasColumnName("pride_flag_id");
+
+ b.HasKey("Id")
+ .HasName("pk_member_flags");
+
+ b.HasIndex("MemberId")
+ .HasDatabaseName("ix_member_flags_member_id");
+
+ b.HasIndex("PrideFlagId")
+ .HasDatabaseName("ix_member_flags_pride_flag_id");
+
+ b.ToTable("member_flags", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notice", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("AuthorId")
+ .HasColumnType("bigint")
+ .HasColumnName("author_id");
+
+ b.Property("EndTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("end_time");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("message");
+
+ b.Property("StartTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start_time");
+
+ b.HasKey("Id")
+ .HasName("pk_notices");
+
+ b.HasIndex("AuthorId")
+ .HasDatabaseName("ix_notices_author_id");
+
+ b.ToTable("notices", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("AcknowledgedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("acknowledged_at");
+
+ b.Property("LocalizationKey")
+ .HasColumnType("text")
+ .HasColumnName("localization_key");
+
+ b.Property>("LocalizationParams")
+ .IsRequired()
+ .HasColumnType("hstore")
+ .HasColumnName("localization_params");
+
+ b.Property("Message")
+ .HasColumnType("text")
+ .HasColumnName("message");
+
+ b.Property("TargetId")
+ .HasColumnType("bigint")
+ .HasColumnName("target_id");
+
+ b.Property("Type")
+ .HasColumnType("integer")
+ .HasColumnName("type");
+
+ b.HasKey("Id")
+ .HasName("pk_notifications");
+
+ b.HasIndex("TargetId")
+ .HasDatabaseName("ix_notifications_target_id");
+
+ b.ToTable("notifications", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("Hash")
+ .HasColumnType("text")
+ .HasColumnName("hash");
+
+ b.Property("LegacyId")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasColumnName("legacy_id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("UserId")
+ .HasColumnType("bigint")
+ .HasColumnName("user_id");
+
+ 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");
+
+ b.ToTable("pride_flags", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("Context")
+ .HasColumnType("text")
+ .HasColumnName("context");
+
+ b.Property("Reason")
+ .HasColumnType("integer")
+ .HasColumnName("reason");
+
+ b.Property("ReporterId")
+ .HasColumnType("bigint")
+ .HasColumnName("reporter_id");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.Property("TargetMemberId")
+ .HasColumnType("bigint")
+ .HasColumnName("target_member_id");
+
+ b.Property("TargetSnapshot")
+ .HasColumnType("text")
+ .HasColumnName("target_snapshot");
+
+ b.Property("TargetType")
+ .HasColumnType("integer")
+ .HasColumnName("target_type");
+
+ b.Property("TargetUserId")
+ .HasColumnType("bigint")
+ .HasColumnName("target_user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_reports");
+
+ b.HasIndex("ReporterId")
+ .HasDatabaseName("ix_reports_reporter_id");
+
+ b.HasIndex("TargetMemberId")
+ .HasDatabaseName("ix_reports_target_member_id");
+
+ b.HasIndex("TargetUserId")
+ .HasDatabaseName("ix_reports_target_user_id");
+
+ b.ToTable("reports", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("ApplicationId")
+ .HasColumnType("bigint")
+ .HasColumnName("application_id");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("Hash")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("hash");
+
+ b.Property("ManuallyExpired")
+ .HasColumnType("boolean")
+ .HasColumnName("manually_expired");
+
+ b.PrimitiveCollection("Scopes")
+ .IsRequired()
+ .HasColumnType("text[]")
+ .HasColumnName("scopes");
+
+ b.Property("UserId")
+ .HasColumnType("bigint")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_tokens");
+
+ b.HasIndex("ApplicationId")
+ .HasDatabaseName("ix_tokens_application_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_tokens_user_id");
+
+ b.ToTable("tokens", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("Avatar")
+ .HasColumnType("text")
+ .HasColumnName("avatar");
+
+ b.Property("AvatarMigrated")
+ .HasColumnType("boolean")
+ .HasColumnName("avatar_migrated");
+
+ b.Property("Bio")
+ .HasColumnType("text")
+ .HasColumnName("bio");
+
+ b.Property>("CustomPreferences")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("custom_preferences");
+
+ b.Property("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasColumnName("deleted_by");
+
+ b.Property("DisplayName")
+ .HasColumnType("text")
+ .HasColumnName("display_name");
+
+ b.Property>("Fields")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("fields");
+
+ b.Property("LastActive")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_active");
+
+ b.Property("LastSidReroll")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_sid_reroll");
+
+ b.Property("LegacyId")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasColumnName("legacy_id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.PrimitiveCollection("Links")
+ .IsRequired()
+ .HasColumnType("text[]")
+ .HasColumnName("links");
+
+ b.Property("ListHidden")
+ .HasColumnType("boolean")
+ .HasColumnName("list_hidden");
+
+ b.Property("MemberTitle")
+ .HasColumnType("text")
+ .HasColumnName("member_title");
+
+ b.Property>("Names")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("names");
+
+ b.Property("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property>("Pronouns")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("pronouns");
+
+ b.Property("Role")
+ .HasColumnType("integer")
+ .HasColumnName("role");
+
+ b.Property("Settings")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("settings");
+
+ b.Property("Sid")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasColumnName("sid")
+ .HasDefaultValueSql("find_free_user_sid()");
+
+ b.Property("Timezone")
+ .HasColumnType("text")
+ .HasColumnName("timezone");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.HasIndex("LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_users_legacy_id");
+
+ b.HasIndex("Sid")
+ .IsUnique()
+ .HasDatabaseName("ix_users_sid");
+
+ b.HasIndex("Username")
+ .IsUnique()
+ .HasDatabaseName("ix_users_username");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.UserFlag", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("PrideFlagId")
+ .HasColumnType("bigint")
+ .HasColumnName("pride_flag_id");
+
+ b.Property("UserId")
+ .HasColumnType("bigint")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_user_flags");
+
+ b.HasIndex("PrideFlagId")
+ .HasDatabaseName("ix_user_flags_pride_flag_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_user_flags_user_id");
+
+ b.ToTable("user_flags", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.Report", "Report")
+ .WithOne("AuditLogEntry")
+ .HasForeignKey("Foxnouns.Backend.Database.Models.AuditLogEntry", "ReportId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("fk_audit_log_reports_report_id");
+
+ b.Navigation("Report");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication")
+ .WithMany()
+ .HasForeignKey("FediverseApplicationId")
+ .HasConstraintName("fk_auth_methods_fediverse_applications_fediverse_application_id");
+
+ b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
+ .WithMany("AuthMethods")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_auth_methods_users_user_id");
+
+ b.Navigation("FediverseApplication");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
+ .WithMany("DataExports")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_data_exports_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
+ .WithMany("Members")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_members_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.MemberFlag", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.Member", null)
+ .WithMany("ProfileFlags")
+ .HasForeignKey("MemberId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_member_flags_members_member_id");
+
+ b.HasOne("Foxnouns.Backend.Database.Models.PrideFlag", "PrideFlag")
+ .WithMany()
+ .HasForeignKey("PrideFlagId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_member_flags_pride_flags_pride_flag_id");
+
+ b.Navigation("PrideFlag");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notice", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.User", "Author")
+ .WithMany()
+ .HasForeignKey("AuthorId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_notices_users_author_id");
+
+ b.Navigation("Author");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.User", "Target")
+ .WithMany()
+ .HasForeignKey("TargetId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_notifications_users_target_id");
+
+ b.Navigation("Target");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.User", null)
+ .WithMany("Flags")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_pride_flags_users_user_id");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.User", "Reporter")
+ .WithMany()
+ .HasForeignKey("ReporterId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_reports_users_reporter_id");
+
+ b.HasOne("Foxnouns.Backend.Database.Models.Member", "TargetMember")
+ .WithMany()
+ .HasForeignKey("TargetMemberId")
+ .HasConstraintName("fk_reports_members_target_member_id");
+
+ b.HasOne("Foxnouns.Backend.Database.Models.User", "TargetUser")
+ .WithMany()
+ .HasForeignKey("TargetUserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_reports_users_target_user_id");
+
+ b.Navigation("Reporter");
+
+ b.Navigation("TargetMember");
+
+ b.Navigation("TargetUser");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application")
+ .WithMany()
+ .HasForeignKey("ApplicationId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tokens_applications_application_id");
+
+ b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tokens_users_user_id");
+
+ b.Navigation("Application");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.UserFlag", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.PrideFlag", "PrideFlag")
+ .WithMany()
+ .HasForeignKey("PrideFlagId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_flags_pride_flags_pride_flag_id");
+
+ b.HasOne("Foxnouns.Backend.Database.Models.User", null)
+ .WithMany("ProfileFlags")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_flags_users_user_id");
+
+ b.Navigation("PrideFlag");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
+ {
+ b.Navigation("ProfileFlags");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
+ {
+ b.Navigation("AuditLogEntry");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
+ {
+ b.Navigation("AuthMethods");
+
+ b.Navigation("DataExports");
+
+ b.Navigation("Flags");
+
+ b.Navigation("Members");
+
+ b.Navigation("ProfileFlags");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Foxnouns.Backend/Database/Migrations/20250410192220_AddAvatarMigrations.cs b/Foxnouns.Backend/Database/Migrations/20250410192220_AddAvatarMigrations.cs
new file mode 100644
index 0000000..ca88605
--- /dev/null
+++ b/Foxnouns.Backend/Database/Migrations/20250410192220_AddAvatarMigrations.cs
@@ -0,0 +1,38 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Foxnouns.Backend.Database.Migrations
+{
+ ///
+ public partial class AddAvatarMigrations : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "avatar_migrated",
+ table: "users",
+ type: "boolean",
+ nullable: false,
+ defaultValue: false
+ );
+
+ migrationBuilder.AddColumn(
+ name: "avatar_migrated",
+ table: "members",
+ type: "boolean",
+ nullable: false,
+ defaultValue: false
+ );
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(name: "avatar_migrated", table: "users");
+
+ migrationBuilder.DropColumn(name: "avatar_migrated", table: "members");
+ }
+ }
+}
diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs
index 70b035d..92db9f9 100644
--- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs
+++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs
@@ -241,6 +241,10 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("text")
.HasColumnName("avatar");
+ b.Property("AvatarMigrated")
+ .HasColumnType("boolean")
+ .HasColumnName("avatar_migrated");
+
b.Property("Bio")
.HasColumnType("text")
.HasColumnName("bio");
@@ -565,6 +569,10 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("text")
.HasColumnName("avatar");
+ b.Property("AvatarMigrated")
+ .HasColumnType("boolean")
+ .HasColumnName("avatar_migrated");
+
b.Property("Bio")
.HasColumnType("text")
.HasColumnName("bio");
diff --git a/Foxnouns.Backend/Database/Models/Member.cs b/Foxnouns.Backend/Database/Models/Member.cs
index 81a01d8..85b39f3 100644
--- a/Foxnouns.Backend/Database/Models/Member.cs
+++ b/Foxnouns.Backend/Database/Models/Member.cs
@@ -29,6 +29,9 @@ public class Member : BaseModel
public List Pronouns { get; set; } = [];
public List Fields { get; set; } = [];
+ // Only used by avatar-proxy and avatar-migration.
+ public bool AvatarMigrated { get; set; } = true;
+
public List ProfileFlags { get; set; } = [];
public Snowflake UserId { get; init; }
diff --git a/Foxnouns.Backend/Database/Models/User.cs b/Foxnouns.Backend/Database/Models/User.cs
index 0e6eb43..fe97b6c 100644
--- a/Foxnouns.Backend/Database/Models/User.cs
+++ b/Foxnouns.Backend/Database/Models/User.cs
@@ -57,6 +57,9 @@ public class User : BaseModel
public Instant? DeletedAt { get; set; }
public Snowflake? DeletedBy { get; set; }
+ // Only used by avatar-proxy and avatar-migration.
+ public bool AvatarMigrated { get; set; } = true;
+
[NotMapped]
public bool? SelfDelete => Deleted ? DeletedBy != null : null;
diff --git a/Foxnouns.Backend/Jobs/MemberAvatarUpdateJob.cs b/Foxnouns.Backend/Jobs/MemberAvatarUpdateJob.cs
index 907dfc4..c1d2df4 100644
--- a/Foxnouns.Backend/Jobs/MemberAvatarUpdateJob.cs
+++ b/Foxnouns.Backend/Jobs/MemberAvatarUpdateJob.cs
@@ -67,6 +67,7 @@ public class MemberAvatarUpdateJob(
await objectStorageService.PutObjectAsync(Path(id, hash), image, "image/webp");
member.Avatar = hash;
+ member.AvatarMigrated = true;
await db.SaveChangesAsync();
if (prevHash != null && prevHash != hash)
diff --git a/Foxnouns.Backend/Jobs/UserAvatarUpdateJob.cs b/Foxnouns.Backend/Jobs/UserAvatarUpdateJob.cs
index 1ab446c..cf7bed0 100644
--- a/Foxnouns.Backend/Jobs/UserAvatarUpdateJob.cs
+++ b/Foxnouns.Backend/Jobs/UserAvatarUpdateJob.cs
@@ -68,6 +68,7 @@ public class UserAvatarUpdateJob(
await objectStorageService.PutObjectAsync(Path(id, hash), image, "image/webp");
user.Avatar = hash;
+ user.AvatarMigrated = true;
await db.SaveChangesAsync();
if (prevHash != null && prevHash != hash)
diff --git a/migration-tools/avatar-migrator/index.js b/migration-tools/avatar-migrator/index.js
new file mode 100644
index 0000000..1415fa8
--- /dev/null
+++ b/migration-tools/avatar-migrator/index.js
@@ -0,0 +1,82 @@
+// TODO: i'm not even sure if this code works. it's not easy to test either. woops
+
+import postgres from "postgres";
+import { config } from "dotenv";
+import { Client } from "minio";
+import { Logger } from "tslog";
+import axios from "axios";
+config();
+const log = new Logger();
+
+const env = (key) => {
+ const value = process.env[key];
+ if (value) return value;
+ throw `No env variable with key $${key} found`;
+};
+
+const oldBaseUrl = env("OLD_BASE_URL");
+const bucket = env("MINIO_BUCKET");
+
+const sql = postgres(env("DATABASE_URL"));
+const minio = new Client({
+ endPoint: env("MINIO_ENDPOINT"),
+ useSSL: true,
+ accessKey: env("MINIO_ACCESS_KEY"),
+ secretKey: env("MINIO_SECRET_KEY"),
+});
+
+const users =
+ await sql`select id::text, username, legacy_id, avatar from users where avatar is not null order by id asc`;
+log.info("have to migrate %d users", users.length);
+
+const migrate = async (user) => {
+ log.debug(
+ "copying /users/%s/%s.webp to /users/%s/avatars/%s.webp",
+ user.legacy_id,
+ user.avatar,
+ user.id,
+ user.avatar
+ );
+
+ try {
+ const file = await axios.get(
+ `${oldBaseUrl}/users/${user.legacy_id}/${user.avatar}.webp`,
+ { responseType: "stream" }
+ );
+ await minio.putObject(
+ bucket,
+ `users/${user.id}/avatars/${user.avatar}.webp`,
+ file.data,
+ file.headers["Content-Length"]
+ );
+
+ log.info("copied avatar for user %s", user.id);
+
+ await sql`update users set avatar_migrated = true where id = ${user.id}::bigint`;
+ } catch (e) {
+ if ("status" in e && e.status === 404) {
+ log.warn(
+ "avatar for user %s/%s is not found. marking it as migrated.",
+ user.id,
+ user.username
+ );
+ await sql`update users set avatar_migrated = true where id = ${user.id}::bigint`;
+ return;
+ }
+
+ log.error(
+ "could not migrate avatar for user %s/%s:",
+ user.id,
+ user.username,
+ e
+ );
+ }
+};
+
+for (let index = 0; index < users.length; index++) {
+ const user = users[index];
+ await migrate(user);
+}
+
+log.info("all users migrated!");
+process.exit();
diff --git a/migration-tools/avatar-migrator/package-lock.json b/migration-tools/avatar-migrator/package-lock.json
new file mode 100644
index 0000000..6aa1e3d
--- /dev/null
+++ b/migration-tools/avatar-migrator/package-lock.json
@@ -0,0 +1,866 @@
+{
+ "name": "avatar-migrator",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "dependencies": {
+ "axios": "^1.8.4",
+ "dotenv": "^16.5.0",
+ "minio": "^8.0.5",
+ "postgres": "^3.4.5",
+ "tslog": "^4.9.3"
+ }
+ },
+ "node_modules/@zxing/text-encoding": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
+ "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==",
+ "license": "(Unlicense OR Apache-2.0)",
+ "optional": true
+ },
+ "node_modules/async": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+ "license": "MIT"
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/available-typed-arrays": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
+ "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "possible-typed-array-names": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/axios": {
+ "version": "1.8.4",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
+ "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.15.6",
+ "form-data": "^4.0.0",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
+ "node_modules/block-stream2": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz",
+ "integrity": "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==",
+ "license": "MIT",
+ "dependencies": {
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/browser-or-node": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-2.1.1.tgz",
+ "integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==",
+ "license": "MIT"
+ },
+ "node_modules/buffer-crc32": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
+ "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/call-bind": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
+ "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.0",
+ "es-define-property": "^1.0.0",
+ "get-intrinsic": "^1.2.4",
+ "set-function-length": "^1.2.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/decode-uri-component": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
+ "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/define-data-property": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
+ "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.5.0",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
+ "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
+ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
+ "license": "MIT"
+ },
+ "node_modules/fast-xml-parser": {
+ "version": "4.5.3",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
+ "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "strnum": "^1.1.1"
+ },
+ "bin": {
+ "fxparser": "src/cli/cli.js"
+ }
+ },
+ "node_modules/filter-obj": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
+ "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.15.9",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
+ "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/for-each": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
+ "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-callable": "^1.2.7"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
+ "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-property-descriptors": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
+ "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
+ "license": "MIT",
+ "dependencies": {
+ "es-define-property": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
+ "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/is-arguments": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
+ "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-callable": {
+ "version": "1.2.7",
+ "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
+ "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-generator-function": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz",
+ "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "get-proto": "^1.0.0",
+ "has-tostringtag": "^1.0.2",
+ "safe-regex-test": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-regex": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
+ "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-typed-array": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
+ "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "which-typed-array": "^1.1.16"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/lodash": {
+ "version": "4.17.21",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+ "license": "MIT"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/minio": {
+ "version": "8.0.5",
+ "resolved": "https://registry.npmjs.org/minio/-/minio-8.0.5.tgz",
+ "integrity": "sha512-/vAze1uyrK2R/DSkVutE4cjVoAowvIQ18RAwn7HrqnLecLlMazFnY0oNBqfuoAWvu7mZIGX75AzpuV05TJeoHg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "async": "^3.2.4",
+ "block-stream2": "^2.1.0",
+ "browser-or-node": "^2.1.1",
+ "buffer-crc32": "^1.0.0",
+ "eventemitter3": "^5.0.1",
+ "fast-xml-parser": "^4.4.1",
+ "ipaddr.js": "^2.0.1",
+ "lodash": "^4.17.21",
+ "mime-types": "^2.1.35",
+ "query-string": "^7.1.3",
+ "stream-json": "^1.8.0",
+ "through2": "^4.0.2",
+ "web-encoding": "^1.1.5",
+ "xml2js": "^0.5.0 || ^0.6.2"
+ },
+ "engines": {
+ "node": "^16 || ^18 || >=20"
+ }
+ },
+ "node_modules/possible-typed-array-names": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+ "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/postgres": {
+ "version": "3.4.5",
+ "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.5.tgz",
+ "integrity": "sha512-cDWgoah1Gez9rN3H4165peY9qfpEo+SA61oQv65O3cRUE1pOEoJWwddwcqKE8XZYjbblOJlYDlLV4h67HrEVDg==",
+ "license": "Unlicense",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://github.com/sponsors/porsager"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
+ "node_modules/query-string": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
+ "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==",
+ "license": "MIT",
+ "dependencies": {
+ "decode-uri-component": "^0.2.2",
+ "filter-obj": "^1.1.0",
+ "split-on-first": "^1.0.0",
+ "strict-uri-encode": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safe-regex-test": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
+ "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "is-regex": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/sax": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
+ "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==",
+ "license": "ISC"
+ },
+ "node_modules/set-function-length": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
+ "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
+ "license": "MIT",
+ "dependencies": {
+ "define-data-property": "^1.1.4",
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2",
+ "get-intrinsic": "^1.2.4",
+ "gopd": "^1.0.1",
+ "has-property-descriptors": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/split-on-first": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
+ "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/stream-chain": {
+ "version": "2.2.5",
+ "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz",
+ "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/stream-json": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz",
+ "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "stream-chain": "^2.2.5"
+ }
+ },
+ "node_modules/strict-uri-encode": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
+ "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/strnum": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
+ "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/through2": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
+ "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==",
+ "license": "MIT",
+ "dependencies": {
+ "readable-stream": "3"
+ }
+ },
+ "node_modules/tslog": {
+ "version": "4.9.3",
+ "resolved": "https://registry.npmjs.org/tslog/-/tslog-4.9.3.tgz",
+ "integrity": "sha512-oDWuGVONxhVEBtschLf2cs/Jy8i7h1T+CpdkTNWQgdAF7DhRo2G8vMCgILKe7ojdEkLhICWgI1LYSSKaJsRgcw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/fullstack-build/tslog?sponsor=1"
+ }
+ },
+ "node_modules/util": {
+ "version": "0.12.5",
+ "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
+ "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "is-arguments": "^1.0.4",
+ "is-generator-function": "^1.0.7",
+ "is-typed-array": "^1.1.3",
+ "which-typed-array": "^1.1.2"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/web-encoding": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/web-encoding/-/web-encoding-1.1.5.tgz",
+ "integrity": "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==",
+ "license": "MIT",
+ "dependencies": {
+ "util": "^0.12.3"
+ },
+ "optionalDependencies": {
+ "@zxing/text-encoding": "0.9.0"
+ }
+ },
+ "node_modules/which-typed-array": {
+ "version": "1.1.19",
+ "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
+ "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==",
+ "license": "MIT",
+ "dependencies": {
+ "available-typed-arrays": "^1.0.7",
+ "call-bind": "^1.0.8",
+ "call-bound": "^1.0.4",
+ "for-each": "^0.3.5",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-tostringtag": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/xml2js": {
+ "version": "0.6.2",
+ "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
+ "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
+ "license": "MIT",
+ "dependencies": {
+ "sax": ">=0.6.0",
+ "xmlbuilder": "~11.0.0"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/xmlbuilder": {
+ "version": "11.0.1",
+ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
+ "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ }
+ }
+ }
+}
diff --git a/migration-tools/avatar-migrator/package.json b/migration-tools/avatar-migrator/package.json
new file mode 100644
index 0000000..e9d3463
--- /dev/null
+++ b/migration-tools/avatar-migrator/package.json
@@ -0,0 +1,10 @@
+{
+ "type": "module",
+ "dependencies": {
+ "axios": "^1.8.4",
+ "dotenv": "^16.5.0",
+ "minio": "^8.0.5",
+ "postgres": "^3.4.5",
+ "tslog": "^4.9.3"
+ }
+}
diff --git a/migration-tools/avatar-proxy/config.example.json b/migration-tools/avatar-proxy/config.example.json
new file mode 100644
index 0000000..d04cb71
--- /dev/null
+++ b/migration-tools/avatar-proxy/config.example.json
@@ -0,0 +1,7 @@
+{
+ "old_avatar_base": "https://pronounscc-legacy.your-s3-host.com/",
+ "new_avatar_base": "http://pronounscc.your-s3-host.com/",
+ "flag_base": "http://pronounscc.your-s3-host.com/",
+ "port": 6100,
+ "database": "postgresql://postgres:password@localhost/postgres"
+}
diff --git a/migration-tools/avatar-proxy/go.mod b/migration-tools/avatar-proxy/go.mod
new file mode 100644
index 0000000..3009181
--- /dev/null
+++ b/migration-tools/avatar-proxy/go.mod
@@ -0,0 +1,17 @@
+module code.vulpine.solutions/sam/Foxnouns.NET/migration-tools/avatar-proxy
+
+go 1.24.2
+
+require (
+ github.com/go-chi/chi/v5 v5.2.1
+ github.com/jackc/pgx/v5 v5.7.4
+)
+
+require (
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ golang.org/x/crypto v0.31.0 // indirect
+ golang.org/x/sync v0.10.0 // indirect
+ golang.org/x/text v0.21.0 // indirect
+)
diff --git a/migration-tools/avatar-proxy/go.sum b/migration-tools/avatar-proxy/go.sum
new file mode 100644
index 0000000..b7ffae7
--- /dev/null
+++ b/migration-tools/avatar-proxy/go.sum
@@ -0,0 +1,30 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
+github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
+github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
+golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/migration-tools/avatar-proxy/main.go b/migration-tools/avatar-proxy/main.go
new file mode 100644
index 0000000..f43f35a
--- /dev/null
+++ b/migration-tools/avatar-proxy/main.go
@@ -0,0 +1,103 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "strconv"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type Proxy struct {
+ OldAvatarBase string `json:"old_avatar_base"`
+ NewAvatarBase string `json:"new_avatar_base"`
+ FlagBase string `json:"flag_base"`
+
+ Port int `json:"port"`
+ Database string `json:"database"`
+
+ db *pgxpool.Pool
+}
+
+type EntityWithAvatar struct {
+ ID uint64
+ LegacyID string
+ Avatar string
+ AvatarMigrated bool
+}
+
+func main() {
+ b, err := os.ReadFile("config.json")
+ if err != nil {
+ log.Fatalln("error reading config:", err)
+ }
+
+ p := &Proxy{}
+ err = json.Unmarshal(b, p)
+ if err != nil {
+ log.Fatalln("error parsing config:", err)
+ }
+
+ p.db, err = pgxpool.New(context.Background(), p.Database)
+ if err != nil {
+ log.Fatalln("error connecting to database:", err)
+ }
+
+ r := chi.NewRouter()
+
+ r.HandleFunc(`/flags/{hash:[\da-f]+}.webp`, func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, fmt.Sprintf("%s%s", p.FlagBase, r.URL.Path), http.StatusTemporaryRedirect)
+ })
+ r.Get(`/members/{id:[\d]+}/avatars/{hash:[\da-f]+}.webp`, p.proxyHandler("member"))
+ r.Get(`/users/{id:[\d]+}/avatars/{hash:[\da-f]+}.webp`, p.proxyHandler("user"))
+ r.NotFound(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ w.Write([]byte("file not found"))
+ })
+
+ log.Printf("serving on port %v", p.Port)
+
+ err = http.ListenAndServe(":"+strconv.Itoa(p.Port), r)
+ if err != nil {
+ log.Fatalf("listening on port %v: %v", p.Port, err)
+ }
+}
+
+func (p *Proxy) proxyHandler(avatarType string) func(http.ResponseWriter, *http.Request) {
+ return func(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ hash := chi.URLParam(r, "hash")
+
+ var e EntityWithAvatar
+ // don't do this normally, kids. avatarType can only be "user" or "member" so it's fine here but this is a BAD idea otherwise.
+ err := p.db.QueryRow(
+ r.Context(), "SELECT id, legacy_id, avatar, avatar_migrated FROM "+avatarType+"s WHERE id = $1 AND avatar = $2",
+ id, hash,
+ ).Scan(&e.ID, &e.LegacyID, &e.Avatar, &e.AvatarMigrated)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ w.WriteHeader(http.StatusNotFound)
+ w.Write([]byte("avatar not found"))
+ return
+ }
+
+ log.Printf("error getting avatar for %s %s: %v", avatarType, id, err)
+ w.WriteHeader(http.StatusInternalServerError)
+ w.Write([]byte("internal server error"))
+ return
+ }
+
+ if e.AvatarMigrated {
+ http.Redirect(w, r, fmt.Sprintf("%s/%ss/%d/avatars/%s.webp", p.NewAvatarBase, avatarType, e.ID, e.Avatar), http.StatusTemporaryRedirect)
+ } else {
+ http.Redirect(w, r, fmt.Sprintf("%s/%ss/%s/%s.webp", p.OldAvatarBase, avatarType, e.LegacyID, e.Avatar), http.StatusTemporaryRedirect)
+ }
+ }
+}