diff --git a/fluxer_admin/src/fluxer_admin/api/instance_config.gleam b/fluxer_admin/src/fluxer_admin/api/instance_config.gleam index 8a117111..3f7b8b51 100644 --- a/fluxer_admin/src/fluxer_admin/api/instance_config.gleam +++ b/fluxer_admin/src/fluxer_admin/api/instance_config.gleam @@ -81,6 +81,25 @@ fn instance_config_decoder() { )) } +pub type SnowflakeReservation { + SnowflakeReservation( + email: String, + snowflake: String, + updated_at: option.Option(String), + ) +} + +fn snowflake_reservation_decoder() { + use email <- decode.field("email", decode.string) + use snowflake <- decode.field("snowflake", decode.string) + use updated_at <- decode.field("updated_at", decode.optional(decode.string)) + decode.success(SnowflakeReservation( + email:, + snowflake:, + updated_at: updated_at, + )) +} + pub fn get_instance_config( ctx: Context, session: Session, @@ -170,3 +189,71 @@ pub fn update_instance_config( Error(_) -> Error(NetworkError) } } + +pub fn list_snowflake_reservations( + ctx: Context, + session: Session, +) -> Result(List(SnowflakeReservation), ApiError) { + let url = ctx.api_endpoint <> "/admin/snowflake-reservations/list" + let body = json.object([]) |> json.to_string + + let assert Ok(req) = request.to(url) + let req = + req + |> request.set_method(http.Post) + |> request.set_header("authorization", "Bearer " <> session.access_token) + |> request.set_header("content-type", "application/json") + |> request.set_body(body) + + case httpc.send(req) { + Ok(resp) if resp.status == 200 -> { + let decoder = { + use reservations <- decode.field( + "reservations", + decode.list(snowflake_reservation_decoder()), + ) + decode.success(reservations) + } + case json.parse(resp.body, decoder) { + Ok(reservations) -> Ok(reservations) + Error(_) -> Error(ServerError) + } + } + Ok(resp) if resp.status == 401 -> Error(Unauthorized) + Ok(resp) if resp.status == 403 -> Error(Forbidden("Access denied")) + Ok(_resp) -> Error(ServerError) + Error(_) -> Error(NetworkError) + } +} + +pub fn add_snowflake_reservation( + ctx: Context, + session: Session, + email: String, + snowflake: String, +) -> Result(Nil, ApiError) { + let fields = [ + #("email", json.string(email)), + #("snowflake", json.string(snowflake)), + ] + common.admin_post_simple( + ctx, + session, + "/admin/snowflake-reservations/add", + fields, + ) +} + +pub fn delete_snowflake_reservation( + ctx: Context, + session: Session, + email: String, +) -> Result(Nil, ApiError) { + let fields = [#("email", json.string(email))] + common.admin_post_simple( + ctx, + session, + "/admin/snowflake-reservations/delete", + fields, + ) +} diff --git a/fluxer_admin/src/fluxer_admin/api/users.gleam b/fluxer_admin/src/fluxer_admin/api/users.gleam index 0870c1e2..39b09fc3 100644 --- a/fluxer_admin/src/fluxer_admin/api/users.gleam +++ b/fluxer_admin/src/fluxer_admin/api/users.gleam @@ -635,7 +635,10 @@ pub fn list_user_sessions( use client_ip <- decode.field("client_ip", decode.string) use client_os <- decode.field("client_os", decode.string) use client_platform <- decode.field("client_platform", decode.string) - use client_location <- decode.field("client_location", decode.optional(decode.string)) + use client_location <- decode.field( + "client_location", + decode.optional(decode.string), + ) decode.success(UserSession( session_id_hash: session_id_hash, created_at: created_at, diff --git a/fluxer_admin/src/fluxer_admin/constants.gleam b/fluxer_admin/src/fluxer_admin/constants.gleam index a90015b0..ec03824d 100644 --- a/fluxer_admin/src/fluxer_admin/constants.gleam +++ b/fluxer_admin/src/fluxer_admin/constants.gleam @@ -375,6 +375,10 @@ pub const acl_instance_config_view = "instance:config:view" pub const acl_instance_config_update = "instance:config:update" +pub const acl_instance_snowflake_reservation_view = "instance:snowflake_reservation:view" + +pub const acl_instance_snowflake_reservation_manage = "instance:snowflake_reservation:manage" + pub type FeatureFlag { FeatureFlag(id: String, name: String, description: String) } diff --git a/fluxer_admin/src/fluxer_admin/pages/instance_config_page.gleam b/fluxer_admin/src/fluxer_admin/pages/instance_config_page.gleam index 5aac560f..cca142a0 100644 --- a/fluxer_admin/src/fluxer_admin/pages/instance_config_page.gleam +++ b/fluxer_admin/src/fluxer_admin/pages/instance_config_page.gleam @@ -15,13 +15,16 @@ //// You should have received a copy of the GNU Affero General Public License //// along with Fluxer. If not, see . +import fluxer_admin/acl import fluxer_admin/api/common import fluxer_admin/api/instance_config import fluxer_admin/components/errors import fluxer_admin/components/flash import fluxer_admin/components/layout import fluxer_admin/components/ui -import fluxer_admin/web.{type Context, type Session, action} +import fluxer_admin/constants +import fluxer_admin/pages/instance_config_sections as sections +import fluxer_admin/web.{type Context, type Session} import gleam/int import gleam/list import gleam/option @@ -38,14 +41,50 @@ pub fn view( flash_data: option.Option(flash.Flash), ) -> Response { let result = instance_config.get_instance_config(ctx, session) + let reservation_view_acl = case current_admin { + option.Some(admin) -> + acl.has_permission( + admin.acls, + constants.acl_instance_snowflake_reservation_view, + ) + option.None -> False + } + let reservation_manage_acl = case current_admin { + option.Some(admin) -> + acl.has_permission( + admin.acls, + constants.acl_instance_snowflake_reservation_manage, + ) + option.None -> False + } let content = case result { - Ok(config) -> - h.div([a.class("space-y-6")], [ + Ok(config) -> { + let base_children = [ ui.heading_page("Instance Configuration"), - render_status_card(config), - render_config_form(ctx, config), - ]) + sections.render_status_card(config), + sections.render_config_form(ctx, config), + ] + + case reservation_view_acl { + True -> + case instance_config.list_snowflake_reservations(ctx, session) { + Ok(reservations) -> { + let children = + list.append(base_children, [ + sections.render_snowflake_reservation_section( + ctx, + reservations, + reservation_manage_acl, + ), + ]) + h.div([a.class("space-y-6")], children) + } + Error(err) -> errors.error_view(err) + } + False -> h.div([a.class("space-y-6")], base_children) + } + } Error(err) -> errors.error_view(err) } @@ -62,218 +101,6 @@ pub fn view( wisp.html_response(element.to_document_string(html), 200) } -fn render_status_card(config: instance_config.InstanceConfig) { - let status_color = case config.manual_review_active_now { - True -> "bg-green-100 text-green-800 border-green-200" - False -> "bg-amber-100 text-amber-800 border-amber-200" - } - - let status_text = case config.manual_review_active_now { - True -> "Manual review is currently ACTIVE" - False -> "Manual review is currently INACTIVE" - } - - h.div([a.class("p-4 rounded-lg border " <> status_color)], [ - h.div([a.class("flex items-center gap-2")], [ - h.span([a.class("text-lg font-semibold")], [element.text(status_text)]), - ]), - case config.manual_review_schedule_enabled { - True -> - h.p([a.class("mt-2 text-sm")], [ - element.text( - "Schedule: " - <> int.to_string(config.manual_review_schedule_start_hour_utc) - <> ":00 UTC to " - <> int.to_string(config.manual_review_schedule_end_hour_utc) - <> ":00 UTC", - ), - ]) - False -> element.none() - }, - ]) -} - -fn render_config_form(ctx: Context, config: instance_config.InstanceConfig) { - ui.card(ui.PaddingMedium, [ - ui.heading_card_with_margin("Manual Review Settings"), - h.p([a.class("text-sm text-neutral-600 mb-4")], [ - element.text( - "Configure whether new registrations require manual review before the account is activated.", - ), - ]), - h.form( - [ - a.method("POST"), - action(ctx, "/instance-config?action=update"), - a.class("space-y-6"), - ], - [ - h.div([a.class("space-y-2")], [ - h.label([a.class("flex items-center gap-3 cursor-pointer")], [ - h.input([ - a.type_("checkbox"), - a.name("manual_review_enabled"), - a.value("true"), - a.class("w-5 h-5 rounded border-neutral-300"), - case config.manual_review_enabled { - True -> a.checked(True) - False -> a.attribute("", "") - }, - ]), - h.span([a.class("text-sm font-medium text-neutral-900")], [ - element.text("Enable manual review for new registrations"), - ]), - ]), - h.p([a.class("text-xs text-neutral-500 ml-8")], [ - element.text( - "When enabled, new accounts will require approval before they can use the platform.", - ), - ]), - ]), - h.div([a.class("border-t border-neutral-200 pt-6")], [ - h.label([a.class("flex items-center gap-3 cursor-pointer mb-4")], [ - h.input([ - a.type_("checkbox"), - a.name("schedule_enabled"), - a.value("true"), - a.class("w-5 h-5 rounded border-neutral-300"), - case config.manual_review_schedule_enabled { - True -> a.checked(True) - False -> a.attribute("", "") - }, - ]), - h.span([a.class("text-sm font-medium text-neutral-900")], [ - element.text("Enable schedule-based activation"), - ]), - ]), - h.p([a.class("text-xs text-neutral-500 mb-4")], [ - element.text( - "When enabled, manual review will only be active during the specified hours (UTC).", - ), - ]), - h.div([a.class("grid grid-cols-2 gap-4")], [ - h.div([a.class("space-y-1")], [ - h.label( - [ - a.for("start_hour"), - a.class("text-sm font-medium text-neutral-700"), - ], - [element.text("Start Hour (UTC)")], - ), - h.select( - [ - a.name("start_hour"), - a.id("start_hour"), - a.class( - "w-full px-3 py-2 border border-neutral-300 rounded text-sm", - ), - ], - list.map(list.range(0, 23), fn(hour) { - h.option( - [ - a.value(int.to_string(hour)), - case - hour == config.manual_review_schedule_start_hour_utc - { - True -> a.selected(True) - False -> a.attribute("", "") - }, - ], - int.to_string(hour) <> ":00", - ) - }), - ), - ]), - h.div([a.class("space-y-1")], [ - h.label( - [ - a.for("end_hour"), - a.class("text-sm font-medium text-neutral-700"), - ], - [element.text("End Hour (UTC)")], - ), - h.select( - [ - a.name("end_hour"), - a.id("end_hour"), - a.class( - "w-full px-3 py-2 border border-neutral-300 rounded text-sm", - ), - ], - list.map(list.range(0, 23), fn(hour) { - h.option( - [ - a.value(int.to_string(hour)), - case hour == config.manual_review_schedule_end_hour_utc { - True -> a.selected(True) - False -> a.attribute("", "") - }, - ], - int.to_string(hour) <> ":00", - ) - }), - ), - ]), - ]), - ]), - h.div([a.class("border-t border-neutral-200 pt-6")], [ - h.div([a.class("space-y-4")], [ - h.div([a.class("space-y-1")], [ - h.label( - [ - a.for("registration_alerts_webhook_url"), - a.class("text-sm font-medium text-neutral-700"), - ], - [element.text("Registration Alerts Webhook URL")], - ), - h.input([ - a.type_("url"), - a.name("registration_alerts_webhook_url"), - a.id("registration_alerts_webhook_url"), - a.value(config.registration_alerts_webhook_url), - a.class( - "w-full px-3 py-2 border border-neutral-300 rounded text-sm", - ), - ]), - h.p([a.class("text-xs text-neutral-500 mt-1")], [ - element.text( - "Webhook URL for receiving alerts about new user registrations.", - ), - ]), - ]), - h.div([a.class("space-y-1")], [ - h.label( - [ - a.for("system_alerts_webhook_url"), - a.class("text-sm font-medium text-neutral-700"), - ], - [element.text("System Alerts Webhook URL")], - ), - h.input([ - a.type_("url"), - a.name("system_alerts_webhook_url"), - a.id("system_alerts_webhook_url"), - a.value(config.system_alerts_webhook_url), - a.class( - "w-full px-3 py-2 border border-neutral-300 rounded text-sm", - ), - ]), - h.p([a.class("text-xs text-neutral-500 mt-1")], [ - element.text( - "Webhook URL for receiving system alerts (virus scan failures, etc.).", - ), - ]), - ]), - ]), - ]), - h.div([a.class("pt-4 border-t border-neutral-200")], [ - ui.button_primary("Save Configuration", "submit", []), - ]), - ], - ), - ]) -} - pub fn handle_action( req: Request, ctx: Context, @@ -282,6 +109,9 @@ pub fn handle_action( ) -> Response { case action_name { "update" -> handle_update(req, ctx, session) + "add-reservation" -> handle_add_snowflake_reservation(req, ctx, session) + "delete-reservation" -> + handle_delete_snowflake_reservation(req, ctx, session) _ -> flash.redirect_with_error(ctx, "/instance-config", "Unknown action") } } @@ -343,3 +173,95 @@ fn handle_update(req: Request, ctx: Context, session: Session) -> Response { ) } } + +fn handle_add_snowflake_reservation( + req: Request, + ctx: Context, + session: Session, +) -> Response { + use form_data <- wisp.require_form(req) + + let email = + list.key_find(form_data.values, "reservation_email") + |> result.unwrap("") + + let snowflake = + list.key_find(form_data.values, "reservation_snowflake") + |> result.unwrap("") + + case email == "" { + True -> + flash.redirect_with_error( + ctx, + "/instance-config", + "Email and Snowflake ID are required.", + ) + False -> + case snowflake == "" { + True -> + flash.redirect_with_error( + ctx, + "/instance-config", + "Email and Snowflake ID are required.", + ) + False -> + case + instance_config.add_snowflake_reservation( + ctx, + session, + email, + snowflake, + ) + { + Ok(_) -> + flash.redirect_with_success( + ctx, + "/instance-config", + "Reservation added successfully.", + ) + Error(_) -> + flash.redirect_with_error( + ctx, + "/instance-config", + "Failed to add reservation.", + ) + } + } + } +} + +fn handle_delete_snowflake_reservation( + req: Request, + ctx: Context, + session: Session, +) -> Response { + use form_data <- wisp.require_form(req) + + let email = + list.key_find(form_data.values, "reservation_email") + |> result.unwrap("") + + case email == "" { + True -> + flash.redirect_with_error( + ctx, + "/instance-config", + "Reservation email is required.", + ) + False -> + case instance_config.delete_snowflake_reservation(ctx, session, email) { + Ok(_) -> + flash.redirect_with_success( + ctx, + "/instance-config", + "Reservation removed successfully.", + ) + Error(_) -> + flash.redirect_with_error( + ctx, + "/instance-config", + "Failed to remove reservation.", + ) + } + } +} diff --git a/fluxer_admin/src/fluxer_admin/pages/instance_config_sections.gleam b/fluxer_admin/src/fluxer_admin/pages/instance_config_sections.gleam new file mode 100644 index 00000000..004a8e6b --- /dev/null +++ b/fluxer_admin/src/fluxer_admin/pages/instance_config_sections.gleam @@ -0,0 +1,396 @@ +import fluxer_admin/api/instance_config +import fluxer_admin/components/ui +import fluxer_admin/web.{type Context, action} +import gleam/int +import gleam/list +import gleam/option +import gleam/order +import gleam/string +import lustre/attribute as a +import lustre/element +import lustre/element/html as h + +pub fn render_status_card( + config: instance_config.InstanceConfig, +) -> element.Element(a) { + let status_color = case config.manual_review_active_now { + True -> "bg-green-100 text-green-800 border-green-200" + False -> "bg-amber-100 text-amber-800 border-amber-200" + } + + let status_text = case config.manual_review_active_now { + True -> "Manual review is currently ACTIVE" + False -> "Manual review is currently INACTIVE" + } + + h.div([a.class("p-4 rounded-lg border " <> status_color)], [ + h.div([a.class("flex items-center gap-2")], [ + h.span([a.class("text-lg font-semibold")], [element.text(status_text)]), + ]), + case config.manual_review_schedule_enabled { + True -> + h.p([a.class("mt-2 text-sm")], [ + element.text( + "Schedule: " + <> int.to_string(config.manual_review_schedule_start_hour_utc) + <> ":00 UTC to " + <> int.to_string(config.manual_review_schedule_end_hour_utc) + <> ":00 UTC", + ), + ]) + False -> element.none() + }, + ]) +} + +pub fn render_config_form( + ctx: Context, + config: instance_config.InstanceConfig, +) -> element.Element(a) { + ui.card(ui.PaddingMedium, [ + ui.heading_card_with_margin("Manual Review Settings"), + h.p([a.class("text-sm text-neutral-600 mb-4")], [ + element.text( + "Configure whether new registrations require manual review before the account is activated.", + ), + ]), + h.form( + [ + a.method("POST"), + action(ctx, "/instance-config?action=update"), + a.class("space-y-6"), + ], + [ + h.div([a.class("space-y-2")], [ + h.label([a.class("flex items-center gap-3 cursor-pointer")], [ + h.input([ + a.type_("checkbox"), + a.name("manual_review_enabled"), + a.value("true"), + a.class("w-5 h-5 rounded border-neutral-300"), + case config.manual_review_enabled { + True -> a.checked(True) + False -> a.attribute("", "") + }, + ]), + h.span([a.class("text-sm font-medium text-neutral-900")], [ + element.text("Enable manual review for new registrations"), + ]), + ]), + h.p([a.class("text-xs text-neutral-500 ml-8")], [ + element.text( + "When enabled, new accounts will require approval before they can use the platform.", + ), + ]), + ]), + h.div([a.class("border-t border-neutral-200 pt-6")], [ + h.label([a.class("flex items-center gap-3 cursor-pointer mb-4")], [ + h.input([ + a.type_("checkbox"), + a.name("schedule_enabled"), + a.value("true"), + a.class("w-5 h-5 rounded border-neutral-300"), + case config.manual_review_schedule_enabled { + True -> a.checked(True) + False -> a.attribute("", "") + }, + ]), + h.span([a.class("text-sm font-medium text-neutral-900")], [ + element.text("Enable schedule-based activation"), + ]), + ]), + h.p([a.class("text-xs text-neutral-500 mb-4")], [ + element.text( + "When enabled, manual review will only be active during the specified hours (UTC).", + ), + ]), + h.div([a.class("grid grid-cols-2 gap-4")], [ + h.div([a.class("space-y-1")], [ + h.label( + [ + a.for("start_hour"), + a.class("text-sm font-medium text-neutral-700"), + ], + [element.text("Start Hour (UTC)")], + ), + h.select( + [ + a.name("start_hour"), + a.id("start_hour"), + a.class( + "w-full px-3 py-2 border border-neutral-300 rounded text-sm", + ), + ], + list.map(list.range(0, 23), fn(hour) { + h.option( + [ + a.value(int.to_string(hour)), + case + hour == config.manual_review_schedule_start_hour_utc + { + True -> a.selected(True) + False -> a.attribute("", "") + }, + ], + int.to_string(hour) <> ":00", + ) + }), + ), + ]), + h.div([a.class("space-y-1")], [ + h.label( + [ + a.for("end_hour"), + a.class("text-sm font-medium text-neutral-700"), + ], + [element.text("End Hour (UTC)")], + ), + h.select( + [ + a.name("end_hour"), + a.id("end_hour"), + a.class( + "w-full px-3 py-2 border border-neutral-300 rounded text-sm", + ), + ], + list.map(list.range(0, 23), fn(hour) { + h.option( + [ + a.value(int.to_string(hour)), + case hour == config.manual_review_schedule_end_hour_utc { + True -> a.selected(True) + False -> a.attribute("", "") + }, + ], + int.to_string(hour) <> ":00", + ) + }), + ), + ]), + ]), + ]), + h.div([a.class("border-t border-neutral-200 pt-6")], [ + h.div([a.class("space-y-4")], [ + h.div([a.class("space-y-1")], [ + h.label( + [ + a.for("registration_alerts_webhook_url"), + a.class("text-sm font-medium text-neutral-700"), + ], + [element.text("Registration Alerts Webhook URL")], + ), + h.input([ + a.type_("url"), + a.name("registration_alerts_webhook_url"), + a.id("registration_alerts_webhook_url"), + a.value(config.registration_alerts_webhook_url), + a.class( + "w-full px-3 py-2 border border-neutral-300 rounded text-sm", + ), + ]), + h.p([a.class("text-xs text-neutral-500 mt-1")], [ + element.text( + "Webhook URL for receiving alerts about new user registrations.", + ), + ]), + ]), + h.div([a.class("space-y-1")], [ + h.label( + [ + a.for("system_alerts_webhook_url"), + a.class("text-sm font-medium text-neutral-700"), + ], + [element.text("System Alerts Webhook URL")], + ), + h.input([ + a.type_("url"), + a.name("system_alerts_webhook_url"), + a.id("system_alerts_webhook_url"), + a.value(config.system_alerts_webhook_url), + a.class( + "w-full px-3 py-2 border border-neutral-300 rounded text-sm", + ), + ]), + h.p([a.class("text-xs text-neutral-500 mt-1")], [ + element.text( + "Webhook URL for receiving system alerts (virus scan failures, etc.).", + ), + ]), + ]), + ]), + ]), + h.div([a.class("pt-4 border-t border-neutral-200")], [ + ui.button_primary("Save Configuration", "submit", []), + ]), + ], + ), + ]) +} + +pub fn render_snowflake_reservation_section( + ctx: Context, + reservations: List(instance_config.SnowflakeReservation), + can_manage: Bool, +) -> element.Element(a) { + let sorted_reservations = + reservations + |> list.sort(fn(a, b) { + case string.compare(a.email, b.email) { + order.Lt -> order.Lt + order.Gt -> order.Gt + order.Eq -> order.Eq + } + }) + + ui.card(ui.PaddingMedium, [ + ui.heading_card_with_margin("Snowflake Reservations"), + h.p([a.class("text-sm text-neutral-500 mb-4")], [ + element.text( + "Reserve specific snowflake IDs for trusted testers. Every reservation maps a normalized email to a hard ID.", + ), + ]), + render_snowflake_reservation_table(ctx, sorted_reservations, can_manage), + case can_manage { + True -> render_add_snowflake_reservation_form(ctx) + False -> + h.p([a.class("text-sm text-neutral-500 italic")], [ + element.text( + "You need additional permissions to modify reservations.", + ), + ]) + }, + ]) +} + +fn render_snowflake_reservation_table( + ctx: Context, + reservations: List(instance_config.SnowflakeReservation), + can_manage: Bool, +) -> element.Element(a) { + let rows = case list.is_empty(reservations) { + True -> [ + h.tr([], [ + h.td( + [a.class("px-6 py-4 text-sm text-neutral-500 italic"), a.colspan(4)], + [element.text("No reservations configured")], + ), + ]), + ] + False -> + list.map(reservations, fn(entry) { + render_reservation_row(ctx, entry, can_manage) + }) + } + + ui.table_container([ + h.table([a.class("min-w-full divide-y divide-neutral-200")], [ + h.thead([a.class("bg-neutral-50")], [ + h.tr([], [ + ui.table_header_cell("Email"), + ui.table_header_cell("Snowflake"), + ui.table_header_cell("Updated At"), + ui.table_header_cell("Actions"), + ]), + ]), + h.tbody([a.class("bg-white divide-y divide-neutral-200")], rows), + ]), + ]) +} + +fn render_reservation_row( + ctx: Context, + entry: instance_config.SnowflakeReservation, + can_manage: Bool, +) -> element.Element(a) { + h.tr([a.class("hover:bg-neutral-50 transition-colors")], [ + h.td([a.class(ui.table_cell_class <> " text-sm text-neutral-900")], [ + element.text(entry.email), + ]), + h.td([a.class(ui.table_cell_class)], [element.text(entry.snowflake)]), + h.td([a.class(ui.table_cell_class)], [ + case entry.updated_at { + option.Some(updated) -> element.text(updated) + option.None -> + h.span([a.class("text-neutral-400 italic")], [element.text("—")]) + }, + ]), + h.td([a.class(ui.table_cell_class)], [ + case can_manage { + True -> render_reservation_action_form(ctx, entry.email) + False -> + h.span([a.class("text-neutral-400 italic")], [element.text("—")]) + }, + ]), + ]) +} + +fn render_reservation_action_form( + ctx: Context, + email: String, +) -> element.Element(a) { + h.form( + [ + a.method("POST"), + action(ctx, "/instance-config?action=delete-reservation"), + a.class("flex items-center gap-2"), + ], + [ + h.input([a.type_("hidden"), a.name("reservation_email"), a.value(email)]), + ui.button_danger("Remove", "submit", []), + ], + ) +} + +fn render_add_snowflake_reservation_form(ctx: Context) -> element.Element(a) { + h.form( + [ + a.method("POST"), + action(ctx, "/instance-config?action=add-reservation"), + a.class("space-y-4"), + ], + [ + h.div([a.class("grid grid-cols-1 gap-4 md:grid-cols-2")], [ + h.div([a.class("space-y-1")], [ + h.label( + [ + a.for("reservation_email"), + a.class("text-sm font-medium text-neutral-700"), + ], + [element.text("Email (normalized)")], + ), + h.input([ + a.type_("email"), + a.name("reservation_email"), + a.id("reservation_email"), + a.class( + "w-full px-3 py-2 border border-neutral-300 rounded text-sm focus:outline-none focus:ring-1 focus:ring-blue-500", + ), + ]), + ]), + h.div([a.class("space-y-1")], [ + h.label( + [ + a.for("reservation_snowflake"), + a.class("text-sm font-medium text-neutral-700"), + ], + [element.text("Snowflake ID")], + ), + h.input([ + a.type_("text"), + a.name("reservation_snowflake"), + a.id("reservation_snowflake"), + a.class( + "w-full px-3 py-2 border border-neutral-300 rounded text-sm focus:outline-none focus:ring-1 focus:ring-blue-500", + ), + ]), + ]), + ]), + h.p([a.class("text-sm text-neutral-500")], [ + element.text( + "Use normalized email addresses (lowercase) when reserving snowflake IDs.", + ), + ]), + ui.button_primary("Reserve Snowflake", "submit", []), + ], + ) +} diff --git a/fluxer_api/src/Constants.ts b/fluxer_api/src/Constants.ts index 4303c028..4d9a68fa 100644 --- a/fluxer_api/src/Constants.ts +++ b/fluxer_api/src/Constants.ts @@ -23,4 +23,5 @@ export * from './constants/Channel'; export * from './constants/Core'; export * from './constants/Gateway'; export * from './constants/Guild'; +export * from './constants/InstanceConfig'; export * from './constants/User'; diff --git a/fluxer_api/src/admin/AdminService.ts b/fluxer_api/src/admin/AdminService.ts index 61fe1076..917b209c 100644 --- a/fluxer_api/src/admin/AdminService.ts +++ b/fluxer_api/src/admin/AdminService.ts @@ -34,6 +34,7 @@ import type {PendingJoinInviteStore} from '~/infrastructure/PendingJoinInviteSto import type {RedisBulkMessageDeletionQueueService} from '~/infrastructure/RedisBulkMessageDeletionQueueService'; import type {SnowflakeService} from '~/infrastructure/SnowflakeService'; import type {UserCacheService} from '~/infrastructure/UserCacheService'; +import {SnowflakeReservationRepository} from '~/instance/SnowflakeReservationRepository'; import type {InviteRepository} from '~/invite/InviteRepository'; import type {InviteService} from '~/invite/InviteService'; import type {RequestCache} from '~/middleware/RequestCacheMiddleware'; @@ -107,6 +108,7 @@ import {AdminMessageService} from './services/AdminMessageService'; import {AdminMessageShredService} from './services/AdminMessageShredService'; import {AdminReportService} from './services/AdminReportService'; import {AdminSearchService} from './services/AdminSearchService'; +import {AdminSnowflakeReservationService} from './services/AdminSnowflakeReservationService'; import {AdminUserService} from './services/AdminUserService'; import {AdminVoiceService} from './services/AdminVoiceService'; @@ -133,6 +135,7 @@ export class AdminService { private readonly searchService: AdminSearchService; private readonly codeGenerationService: AdminCodeGenerationService; private readonly assetPurgeService: AdminAssetPurgeService; + private readonly snowflakeReservationService: AdminSnowflakeReservationService; constructor( private readonly userRepository: IUserRepository, @@ -235,6 +238,12 @@ export class AdminService { snowflakeService: this.snowflakeService, auditService: this.auditService, }); + + this.snowflakeReservationService = new AdminSnowflakeReservationService({ + repository: new SnowflakeReservationRepository(), + cacheService: this.cacheService, + auditService: this.auditService, + }); this.codeGenerationService = new AdminCodeGenerationService(this.userRepository); } @@ -242,6 +251,22 @@ export class AdminService { return this.userService.lookupUser(data); } + async listSnowflakeReservations() { + return this.snowflakeReservationService.listReservations(); + } + + async setSnowflakeReservation( + data: {email: string; snowflake: string}, + adminUserId: UserID, + auditLogReason: string | null, + ) { + return this.snowflakeReservationService.setReservation(data, adminUserId, auditLogReason); + } + + async deleteSnowflakeReservation(data: {email: string}, adminUserId: UserID, auditLogReason: string | null) { + return this.snowflakeReservationService.deleteReservation(data, adminUserId, auditLogReason); + } + async updateUserFlags(args: { userId: UserID; data: {addFlags: Array; removeFlags: Array}; diff --git a/fluxer_api/src/admin/controllers/SnowflakeReservationAdminController.ts b/fluxer_api/src/admin/controllers/SnowflakeReservationAdminController.ts new file mode 100644 index 00000000..fee216be --- /dev/null +++ b/fluxer_api/src/admin/controllers/SnowflakeReservationAdminController.ts @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2026 Fluxer Contributors + * + * This file is part of Fluxer. + * + * Fluxer is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Fluxer is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Fluxer. If not, see . + */ + +import type {HonoApp} from '~/App'; +import type { + AddSnowflakeReservationRequest, + DeleteSnowflakeReservationRequest, + ListSnowflakeReservationsResponse, +} from '~/admin/models/SnowflakeReservationTypes'; +import {AdminACLs} from '~/Constants'; +import {requireAdminACL} from '~/middleware/AdminMiddleware'; +import {RateLimitMiddleware} from '~/middleware/RateLimitMiddleware'; +import {RateLimitConfigs} from '~/RateLimitConfig'; +import {EmailType, Int64Type, z} from '~/Schema'; +import {Validator} from '~/Validator'; + +export const SnowflakeReservationAdminController = (app: HonoApp) => { + app.post( + '/admin/snowflake-reservations/list', + RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP), + requireAdminACL(AdminACLs.INSTANCE_SNOWFLAKE_RESERVATION_VIEW), + async (ctx) => { + const adminService = ctx.get('adminService'); + const reservations = await adminService.listSnowflakeReservations(); + + return ctx.json({ + reservations, + }); + }, + ); + + app.post( + '/admin/snowflake-reservations/add', + RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), + requireAdminACL(AdminACLs.INSTANCE_SNOWFLAKE_RESERVATION_MANAGE), + Validator( + 'json', + z.object({ + email: EmailType, + snowflake: Int64Type.transform((val) => val.toString()), + }), + ), + async (ctx) => { + const adminService = ctx.get('adminService'); + const adminUserId = ctx.get('adminUserId'); + const auditLogReason = ctx.get('auditLogReason'); + const data = ctx.req.valid('json') as AddSnowflakeReservationRequest; + + await adminService.setSnowflakeReservation(data, adminUserId, auditLogReason); + return ctx.json({success: true}); + }, + ); + + app.post( + '/admin/snowflake-reservations/delete', + RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY), + requireAdminACL(AdminACLs.INSTANCE_SNOWFLAKE_RESERVATION_MANAGE), + Validator('json', z.object({email: EmailType})), + async (ctx) => { + const adminService = ctx.get('adminService'); + const adminUserId = ctx.get('adminUserId'); + const auditLogReason = ctx.get('auditLogReason'); + const data = ctx.req.valid('json') as DeleteSnowflakeReservationRequest; + + await adminService.deleteSnowflakeReservation(data, adminUserId, auditLogReason); + return ctx.json({success: true}); + }, + ); +}; diff --git a/fluxer_api/src/admin/controllers/index.ts b/fluxer_api/src/admin/controllers/index.ts index d2ec3758..a9506af1 100644 --- a/fluxer_api/src/admin/controllers/index.ts +++ b/fluxer_api/src/admin/controllers/index.ts @@ -31,6 +31,7 @@ import {InstanceConfigAdminController} from './InstanceConfigAdminController'; import {MessageAdminController} from './MessageAdminController'; import {ReportAdminController} from './ReportAdminController'; import {SearchAdminController} from './SearchAdminController'; +import {SnowflakeReservationAdminController} from './SnowflakeReservationAdminController'; import {UserAdminController} from './UserAdminController'; import {VerificationAdminController} from './VerificationAdminController'; import {VoiceAdminController} from './VoiceAdminController'; @@ -42,6 +43,7 @@ export const registerAdminControllers = (app: HonoApp) => { AssetAdminController(app); BanAdminController(app); InstanceConfigAdminController(app); + SnowflakeReservationAdminController(app); MessageAdminController(app); BulkAdminController(app); AuditLogAdminController(app); diff --git a/fluxer_api/src/admin/models/SnowflakeReservationTypes.ts b/fluxer_api/src/admin/models/SnowflakeReservationTypes.ts new file mode 100644 index 00000000..24d984f2 --- /dev/null +++ b/fluxer_api/src/admin/models/SnowflakeReservationTypes.ts @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2026 Fluxer Contributors + * + * This file is part of Fluxer. + * + * Fluxer is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Fluxer is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Fluxer. If not, see . + */ + +export interface SnowflakeReservationEntry { + email: string; + snowflake: string; + updated_at: string | null; +} + +export interface ListSnowflakeReservationsResponse { + reservations: Array; +} + +export interface AddSnowflakeReservationRequest { + email: string; + snowflake: string; +} + +export interface DeleteSnowflakeReservationRequest { + email: string; +} diff --git a/fluxer_api/src/admin/models/index.ts b/fluxer_api/src/admin/models/index.ts index d3988c56..1caca3e9 100644 --- a/fluxer_api/src/admin/models/index.ts +++ b/fluxer_api/src/admin/models/index.ts @@ -25,6 +25,7 @@ export * from './CodeRequestTypes'; export * from './GuildRequestTypes'; export * from './GuildTypes'; export * from './MessageTypes'; +export * from './SnowflakeReservationTypes'; export * from './UserRequestTypes'; export * from './UserTypes'; export * from './VoiceTypes'; diff --git a/fluxer_api/src/admin/services/AdminSnowflakeReservationService.ts b/fluxer_api/src/admin/services/AdminSnowflakeReservationService.ts new file mode 100644 index 00000000..d7eb1f27 --- /dev/null +++ b/fluxer_api/src/admin/services/AdminSnowflakeReservationService.ts @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2026 Fluxer Contributors + * + * This file is part of Fluxer. + * + * Fluxer is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Fluxer is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Fluxer. If not, see . + */ + +import type {UserID} from '~/BrandedTypes'; +import {SNOWFLAKE_RESERVATION_REFRESH_CHANNEL} from '~/constants/InstanceConfig'; +import {InputValidationError} from '~/Errors'; +import type {ICacheService} from '~/infrastructure/ICacheService'; +import type {SnowflakeReservationRepository} from '~/instance/SnowflakeReservationRepository'; +import type {AdminAuditService} from './AdminAuditService'; + +interface AdminSnowflakeReservationServiceDeps { + repository: SnowflakeReservationRepository; + cacheService: ICacheService; + auditService: AdminAuditService; +} + +export class AdminSnowflakeReservationService { + constructor(private readonly deps: AdminSnowflakeReservationServiceDeps) {} + + async listReservations() { + const {repository} = this.deps; + const entries = await repository.listReservations(); + return entries.map((entry) => ({ + email: entry.emailKey, + snowflake: entry.snowflake.toString(), + updated_at: entry.updatedAt ? entry.updatedAt.toISOString() : null, + })); + } + + async setReservation(data: {email: string; snowflake: string}, adminUserId: UserID, auditLogReason: string | null) { + const {repository, cacheService, auditService} = this.deps; + const emailLower = data.email.toLowerCase(); + + if (!emailLower) { + throw InputValidationError.create('email', 'Invalid email address'); + } + + let snowflakeValue: bigint; + try { + snowflakeValue = BigInt(data.snowflake); + } catch { + throw InputValidationError.create('snowflake', 'Invalid snowflake'); + } + + await repository.setReservation(emailLower, snowflakeValue); + await cacheService.publish(SNOWFLAKE_RESERVATION_REFRESH_CHANNEL, 'refresh'); + + await auditService.createAuditLog({ + adminUserId, + targetType: 'snowflake_reservation', + targetId: BigInt(0), + action: 'set_snowflake_reservation', + auditLogReason, + metadata: new Map([ + ['email', emailLower], + ['snowflake', snowflakeValue.toString()], + ]), + }); + } + + async deleteReservation(data: {email: string}, adminUserId: UserID, auditLogReason: string | null) { + const {repository, cacheService, auditService} = this.deps; + const emailLower = data.email.toLowerCase(); + + if (!emailLower) { + throw InputValidationError.create('email', 'Invalid email address'); + } + + await repository.deleteReservation(emailLower); + await cacheService.publish(SNOWFLAKE_RESERVATION_REFRESH_CHANNEL, 'refresh'); + + await auditService.createAuditLog({ + adminUserId, + targetType: 'snowflake_reservation', + targetId: BigInt(0), + action: 'delete_snowflake_reservation', + auditLogReason, + metadata: new Map([['email', emailLower]]), + }); + } +} diff --git a/fluxer_api/src/auth/AuthService.ts b/fluxer_api/src/auth/AuthService.ts index 9a6cff70..751c03be 100644 --- a/fluxer_api/src/auth/AuthService.ts +++ b/fluxer_api/src/auth/AuthService.ts @@ -49,6 +49,7 @@ import type {PendingJoinInviteStore} from '~/infrastructure/PendingJoinInviteSto import type {RedisAccountDeletionQueueService} from '~/infrastructure/RedisAccountDeletionQueueService'; import type {RedisActivityTracker} from '~/infrastructure/RedisActivityTracker'; import type {SnowflakeService} from '~/infrastructure/SnowflakeService'; +import type {SnowflakeReservationService} from '~/instance/SnowflakeReservationService'; import type {InviteService} from '~/invite/InviteService'; import type {AuthSession, User} from '~/Models'; import type {RequestCache} from '~/middleware/RequestCacheMiddleware'; @@ -157,6 +158,7 @@ export class AuthService implements IAuthService { emailServiceDep: IEmailService, smsService: ISMSService, snowflakeService: SnowflakeService, + snowflakeReservationService: SnowflakeReservationService, discriminatorService: IDiscriminatorService, redisAccountDeletionQueue: RedisAccountDeletionQueueService, redisActivityTracker: RedisActivityTracker, @@ -200,6 +202,7 @@ export class AuthService implements IAuthService { rateLimitService, emailServiceDep, snowflakeService, + snowflakeReservationService, discriminatorService, redisActivityTracker, pendingJoinInviteStore, diff --git a/fluxer_api/src/auth/services/AuthRegistrationService.ts b/fluxer_api/src/auth/services/AuthRegistrationService.ts index 838344cb..22441b4e 100644 --- a/fluxer_api/src/auth/services/AuthRegistrationService.ts +++ b/fluxer_api/src/auth/services/AuthRegistrationService.ts @@ -17,7 +17,6 @@ * along with Fluxer. If not, see . */ -import crypto from 'node:crypto'; import Bowser from 'bowser'; import {types} from 'cassandra-driver'; import type {RegisterRequest} from '~/auth/AuthModel'; @@ -34,6 +33,7 @@ import type {PendingJoinInviteStore} from '~/infrastructure/PendingJoinInviteSto import type {RedisActivityTracker} from '~/infrastructure/RedisActivityTracker'; import type {SnowflakeService} from '~/infrastructure/SnowflakeService'; import {InstanceConfigRepository} from '~/instance/InstanceConfigRepository'; +import type {SnowflakeReservationService} from '~/instance/SnowflakeReservationService'; import type {InviteService} from '~/invite/InviteService'; import {Logger} from '~/Logger'; import {getUserSearchService} from '~/Meilisearch'; @@ -134,15 +134,6 @@ function parseDobLocalDate(dateOfBirth: string): types.LocalDate { } } -function safeJsonParse(value: string): T | null { - try { - return JSON.parse(value) as T; - } catch (error) { - Logger.warn({error}, 'Failed to parse JSON from environment variable'); - return null; - } -} - interface RegisterParams { data: RegisterRequest; request: Request; @@ -158,6 +149,7 @@ export class AuthRegistrationService { private rateLimitService: IRateLimitService, private emailService: IEmailService, private snowflakeService: SnowflakeService, + private snowflakeReservationService: SnowflakeReservationService, private discriminatorService: IDiscriminatorService, private redisActivityTracker: RedisActivityTracker, private pendingJoinInviteStore: PendingJoinInviteStore, @@ -457,23 +449,12 @@ export class AuthRegistrationService { } private generateUserId(emailKey: string | null): UserID { - const mappingJson = process.env.EARLY_TESTER_EMAIL_HASH_TO_SNOWFLAKE; - - if (emailKey && mappingJson) { - const mapping = safeJsonParse>(mappingJson); - if (mapping) { - const emailHash = crypto.createHash('sha256').update(emailKey).digest('hex'); - const mapped = mapping[emailHash]; - if (mapped) { - try { - return createUserID(BigInt(mapped)); - } catch (error) { - Logger.warn({error}, 'Invalid snowflake mapping value; falling back to generated ID'); - } - } + if (emailKey) { + const reserved = this.snowflakeReservationService.getReservedSnowflake(emailKey); + if (reserved) { + return createUserID(reserved); } } - return createUserID(this.snowflakeService.generate()); } diff --git a/fluxer_api/src/constants/API.ts b/fluxer_api/src/constants/API.ts index a3bc0a18..d9ebb23b 100644 --- a/fluxer_api/src/constants/API.ts +++ b/fluxer_api/src/constants/API.ts @@ -279,6 +279,8 @@ export const AdminACLs = { INSTANCE_CONFIG_VIEW: 'instance:config:view', INSTANCE_CONFIG_UPDATE: 'instance:config:update', + INSTANCE_SNOWFLAKE_RESERVATION_VIEW: 'instance:snowflake_reservation:view', + INSTANCE_SNOWFLAKE_RESERVATION_MANAGE: 'instance:snowflake_reservation:manage', METRICS_VIEW: 'metrics:view', diff --git a/fluxer_api/src/constants/InstanceConfig.ts b/fluxer_api/src/constants/InstanceConfig.ts new file mode 100644 index 00000000..28fa4a92 --- /dev/null +++ b/fluxer_api/src/constants/InstanceConfig.ts @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2026 Fluxer Contributors + * + * This file is part of Fluxer. + * + * Fluxer is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Fluxer is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Fluxer. If not, see . + */ + +export const SNOWFLAKE_RESERVATION_KEY_PREFIX = 'snowflake_reservation:'; +export const SNOWFLAKE_RESERVATION_REFRESH_CHANNEL = 'snowflake_reservation:refresh'; diff --git a/fluxer_api/src/instance/SnowflakeReservationRepository.ts b/fluxer_api/src/instance/SnowflakeReservationRepository.ts new file mode 100644 index 00000000..b93c9e33 --- /dev/null +++ b/fluxer_api/src/instance/SnowflakeReservationRepository.ts @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2026 Fluxer Contributors + * + * This file is part of Fluxer. + * + * Fluxer is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Fluxer is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Fluxer. If not, see . + */ + +import {SNOWFLAKE_RESERVATION_KEY_PREFIX} from '~/constants/InstanceConfig'; +import {deleteOneOrMany, fetchMany, upsertOne} from '~/database/Cassandra'; +import type {InstanceConfigurationRow} from '~/database/CassandraTypes'; +import {Logger} from '~/Logger'; +import {InstanceConfiguration} from '~/Tables'; + +const FETCH_ALL_CONFIG_QUERY = InstanceConfiguration.selectCql(); + +export interface SnowflakeReservationConfig { + emailKey: string; + snowflake: bigint; + updatedAt: Date | null; +} + +export class SnowflakeReservationRepository { + async listReservations(): Promise> { + const rows = await fetchMany(FETCH_ALL_CONFIG_QUERY, {}); + const reservations: Array = []; + + for (const row of rows) { + if (!row.key.startsWith(SNOWFLAKE_RESERVATION_KEY_PREFIX) || row.value == null || row.value.trim().length === 0) { + continue; + } + + const emailKey = row.key.slice(SNOWFLAKE_RESERVATION_KEY_PREFIX.length); + if (!emailKey) continue; + + const snowflakeString = row.value.trim(); + try { + const snowflake = BigInt(snowflakeString); + reservations.push({ + emailKey, + snowflake, + updatedAt: row.updated_at ?? null, + }); + } catch (error) { + Logger.warn({key: row.key, value: row.value, error}, 'Skipping invalid snowflake reservation value'); + } + } + + return reservations; + } + + async setReservation(emailKey: string, snowflake: bigint): Promise { + await upsertOne( + InstanceConfiguration.upsertAll({ + key: `${SNOWFLAKE_RESERVATION_KEY_PREFIX}${emailKey}`, + value: snowflake.toString(), + updated_at: new Date(), + }), + ); + } + + async deleteReservation(emailKey: string): Promise { + await deleteOneOrMany( + InstanceConfiguration.deleteCql({ + where: InstanceConfiguration.where.eq('key'), + }), + {key: `${SNOWFLAKE_RESERVATION_KEY_PREFIX}${emailKey}`}, + ); + } +} diff --git a/fluxer_api/src/instance/SnowflakeReservationService.ts b/fluxer_api/src/instance/SnowflakeReservationService.ts new file mode 100644 index 00000000..5630bdbb --- /dev/null +++ b/fluxer_api/src/instance/SnowflakeReservationService.ts @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2026 Fluxer Contributors + * + * This file is part of Fluxer. + * + * Fluxer is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Fluxer is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Fluxer. If not, see . + */ + +import type {Redis} from 'ioredis'; +import {SNOWFLAKE_RESERVATION_REFRESH_CHANNEL} from '~/constants/InstanceConfig'; +import {Logger} from '~/Logger'; +import type {SnowflakeReservationConfig, SnowflakeReservationRepository} from './SnowflakeReservationRepository'; + +export class SnowflakeReservationService { + private reservations = new Map(); + private initialized = false; + private reloadPromise: Promise | null = null; + + constructor( + private repository: SnowflakeReservationRepository, + private redisSubscriber: Redis | null, + ) {} + + async initialize(): Promise { + if (this.initialized) { + return; + } + + await this.reload(); + this.initialized = true; + + if (this.redisSubscriber) { + try { + await this.redisSubscriber.subscribe(SNOWFLAKE_RESERVATION_REFRESH_CHANNEL); + this.redisSubscriber.on('message', (channel) => { + if (channel === SNOWFLAKE_RESERVATION_REFRESH_CHANNEL) { + this.reload().catch((error) => { + Logger.error({error}, 'Failed to reload snowflake reservations'); + }); + } + }); + } catch (error) { + Logger.error({error}, 'Failed to subscribe to snowflake reservation refresh channel'); + } + } + } + + async reload(): Promise { + if (this.reloadPromise) { + return this.reloadPromise; + } + + this.reloadPromise = (async () => { + const entries = await this.repository.listReservations(); + this.reservations = this.buildLookup(entries); + })() + .catch((error) => { + Logger.error({error}, 'Failed to reload snowflake reservations from the database'); + throw error; + }) + .finally(() => { + this.reloadPromise = null; + }); + + return this.reloadPromise; + } + + getReservedSnowflake(emailKey: string | null): bigint | null { + if (!emailKey) { + return null; + } + return this.reservations.get(emailKey) ?? null; + } + + private buildLookup(entries: Array): Map { + const lookup = new Map(); + for (const entry of entries) { + lookup.set(entry.emailKey, entry.snowflake); + } + return lookup; + } +} diff --git a/fluxer_api/src/middleware/ServiceMiddleware.ts b/fluxer_api/src/middleware/ServiceMiddleware.ts index 18f43370..da1e1a0f 100644 --- a/fluxer_api/src/middleware/ServiceMiddleware.ts +++ b/fluxer_api/src/middleware/ServiceMiddleware.ts @@ -77,6 +77,8 @@ import {UnfurlerService as ProdUnfurlerService} from '~/infrastructure/UnfurlerS import {UserCacheService} from '~/infrastructure/UserCacheService'; import {VirusScanService as ProdVirusScanService} from '~/infrastructure/VirusScanService'; import {VoiceRoomStore} from '~/infrastructure/VoiceRoomStore'; +import {SnowflakeReservationRepository} from '~/instance/SnowflakeReservationRepository'; +import {SnowflakeReservationService} from '~/instance/SnowflakeReservationService'; import {InviteRepository as ProdInviteRepository} from '~/invite/InviteRepository'; import {InviteService} from '~/invite/InviteService'; import {getReportSearchService} from '~/Meilisearch'; @@ -145,6 +147,13 @@ const assetDeletionQueue: IAssetDeletionQueue = new AssetDeletionQueue(redis); const featureFlagRepository = new FeatureFlagRepository(); const featureFlagService = new FeatureFlagService(featureFlagRepository, cacheService); let featureFlagServiceInitialized = false; +const snowflakeReservationRepository = new SnowflakeReservationRepository(); +const snowflakeReservationSubscriber = new Redis(Config.redis.url); +const snowflakeReservationService = new SnowflakeReservationService( + snowflakeReservationRepository, + snowflakeReservationSubscriber, +); +let snowflakeReservationServiceInitialized = false; let voiceTopology: VoiceTopology | null = null; let voiceAvailabilityService: VoiceAvailabilityService | null = null; @@ -198,6 +207,11 @@ export const ServiceMiddleware = createMiddleware(async (ctx, next) => featureFlagServiceInitialized = true; } + if (!snowflakeReservationServiceInitialized) { + await snowflakeReservationService.initialize(); + snowflakeReservationServiceInitialized = true; + } + const userRepository = new UserRepository(); const guildRepository = new GuildRepository(); const channelRepository = new ChannelRepository(); @@ -377,6 +391,7 @@ export const ServiceMiddleware = createMiddleware(async (ctx, next) => emailService, smsService, snowflakeService, + snowflakeReservationService, discriminatorService, redisAccountDeletionQueue, redisActivityTracker,