diff --git a/Gemfile b/Gemfile
index c20323811fa47c996a5015dbe4f701c886fc6a61..b09b258fd0be445e8d937448b41d0d1193a11a69 100644
--- a/Gemfile
+++ b/Gemfile
@@ -100,3 +100,5 @@ group :development do
   gem 'spring', '~> 4.0'
   gem 'web-console', '~> 4.2'
 end
+
+gem 'maintenance_tasks', '~> 2.1.1'
diff --git a/Gemfile.lock b/Gemfile.lock
index 659cfa7f242777faae47e22f99e6a7f23a3dbca0..c97b4b4aafad38a77a0624ae6388730332979201 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -164,6 +164,8 @@ GEM
       actionview (>= 5.0.0)
       activesupport (>= 5.0.0)
     jmespath (1.6.1)
+    job-iteration (1.3.6)
+      activejob (>= 5.2)
     jquery-rails (4.5.0)
       rails-dom-testing (>= 1, < 3)
       railties (>= 4.2.0)
@@ -189,6 +191,12 @@ GEM
       net-imap
       net-pop
       net-smtp
+    maintenance_tasks (2.1.1)
+      actionpack (>= 6.0)
+      activejob (>= 6.0)
+      activerecord (>= 6.0)
+      job-iteration (~> 1.3.6)
+      railties (>= 6.0)
     marcel (1.0.4)
     matrix (0.4.2)
     memory_profiler (1.0.0)
@@ -415,6 +423,7 @@ DEPENDENCIES
   jquery-rails (~> 4.5.0)
   letter_opener_web (~> 2.0)
   listen (~> 3.7)
+  maintenance_tasks (~> 2.1.1)
   memory_profiler (~> 1.0)
   minitest (~> 5.16.0)
   minitest-ci (~> 3.4.0)
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index bcd8aa87ef08eb5fa4c90d2d1613d9276032d058..1b4aec6f0c2d168789e5f5b87e0e5ac2529c60b7 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -355,25 +355,24 @@ class UsersController < ApplicationController
     render layout: 'without_sidebar'
   end
 
-  def validate_profile_website(profile_params)
-    uri = profile_params[:website]
+  def cleaned_profile_websites(profile_params)
+    sites = profile_params[:user_websites_attributes]
 
-    if URI.parse(uri).instance_of?(URI::Generic)
-      # URI::Generic indicates the user didn't include a protocol, so we'll add one now so that it can be
-      # parsed correctly in the view later on.
-      profile_params[:website] = "https://#{uri}"
+    sites.transform_values do |w|
+      w.merge({ label: w[:label].presence, url: w[:url].presence })
     end
-  rescue URI::InvalidURIError
-    profile_params.delete(:website)
-    flash[:danger] = 'Invalid profile website link.'
   end
 
   def update_profile
-    profile_params = params.require(:user).permit(:username, :profile_markdown, :website, :twitter, :discord)
-    profile_params[:twitter] = profile_params[:twitter].delete('@')
+    profile_params = params.require(:user).permit(:username,
+                                                  :profile_markdown,
+                                                  :website,
+                                                  :discord,
+                                                  :twitter,
+                                                  user_websites_attributes: [:id, :label, :url])
 
-    if profile_params[:website].present?
-      validate_profile_website(profile_params)
+    if profile_params[:user_websites_attributes].present?
+      profile_params[:user_websites_attributes] = cleaned_profile_websites(profile_params)
     end
 
     @user = current_user
@@ -389,8 +388,14 @@ class UsersController < ApplicationController
       end
     end
 
-    profile_rendered = helpers.post_markdown(:user, :profile_markdown)
-    if @user.update(profile_params.merge(profile: profile_rendered))
+    if params[:user][:profile_markdown].present?
+      profile_rendered = helpers.post_markdown(:user, :profile_markdown)
+      profile_params = profile_params.merge(profile: profile_rendered)
+    end
+
+    status = @user.update(profile_params)
+
+    if status
       flash[:success] = 'Your profile details were updated.'
       redirect_to user_path(current_user)
     else
diff --git a/app/models/user.rb b/app/models/user.rb
index ad7e85c09e2379075b5cd0ddf916651b387d5bfc..04e1219f786f5e535612a64f7cd665250dd6631b 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -27,6 +27,8 @@ class User < ApplicationRecord
   has_many :comment_threads_locked, class_name: 'CommentThread', foreign_key: :locked_by_id, dependent: :nullify
   has_many :category_filter_defaults, dependent: :destroy
   has_many :filters, dependent: :destroy
+  has_many :user_websites, dependent: :destroy
+  accepts_nested_attributes_for :user_websites
   belongs_to :deleted_by, required: false, class_name: 'User'
 
   validates :username, presence: true, length: { minimum: 3, maximum: 50 }
@@ -43,7 +45,7 @@ class User < ApplicationRecord
   scope :active, -> { where(deleted: false) }
   scope :deleted, -> { where(deleted: true) }
 
-  after_create :send_welcome_tour_message
+  after_create :send_welcome_tour_message, :ensure_websites
 
   def self.list_includes
     includes(:posts, :avatar_attachment)
@@ -61,6 +63,12 @@ class User < ApplicationRecord
     community_user.trust_level
   end
 
+  # Checks whether this user is the same as a given user
+  # @param [User] user user to compare with
+  def same_as?(user)
+    id == user.id
+  end
+
   # This class makes heavy use of predicate names, and their use is prevalent throughout the codebase
   # because of the importance of these methods.
   # rubocop:disable Naming/PredicateName
@@ -130,6 +138,18 @@ class User < ApplicationRecord
     website.nil? ? website : URI.parse(website).hostname
   end
 
+  def valid_websites_for
+    user_websites.where.not(url: [nil, '']).order(position: :asc)
+  end
+
+  def ensure_websites
+    pos = user_websites.size
+    while pos < UserWebsite::MAX_ROWS
+      pos += 1
+      UserWebsite.create(user_id: id, position: pos)
+    end
+  end
+
   def is_moderator
     is_global_moderator || community_user&.is_moderator || is_admin || community_user&.privilege?('mod') || false
   end
diff --git a/app/models/user_website.rb b/app/models/user_website.rb
new file mode 100644
index 0000000000000000000000000000000000000000..6b50d909f50dce78f3c31653597d2c277411baeb
--- /dev/null
+++ b/app/models/user_website.rb
@@ -0,0 +1,6 @@
+class UserWebsite < ApplicationRecord
+  belongs_to :user
+  default_scope { order(:position) }
+
+  MAX_ROWS = 3
+end
diff --git a/app/tasks/maintenance/initialize_user_websites_task.rb b/app/tasks/maintenance/initialize_user_websites_task.rb
new file mode 100644
index 0000000000000000000000000000000000000000..80a91b9483f0a45db59489138f6752dbad332f26
--- /dev/null
+++ b/app/tasks/maintenance/initialize_user_websites_task.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Maintenance
+  class InitializeUserWebsitesTask < MaintenanceTasks::Task
+    def collection
+      User.all
+    end
+
+    def process(user)
+      unless user.user_websites.exists?(position: 1)
+        if user.website.present?
+          UserWebsite.create!(user_id: user.id, position: 1, label: 'website', url: user.website)
+        else
+          UserWebsite.create!(user_id: user.id, position: 1)
+        end
+      end
+
+      unless user.user_websites.exists?(position: 2)
+        if user.twitter.present?
+          UserWebsite.create!(user_id: user.id, position: 2, label: 'Twitter',
+                              url: "https://twitter.com/#{user.twitter}")
+        else
+          UserWebsite.create!(user_id: user.id, position: 2)
+        end
+      end
+
+      # This check *should* be superfluous, but just in case...
+      unless user.user_websites.exists?(position: 3)
+        UserWebsite.create!(user_id: user.id, position: 3)
+      end
+    end
+  end
+end
diff --git a/app/views/users/edit_profile.html.erb b/app/views/users/edit_profile.html.erb
index 4dbe1d2da527e60ec8ef3d1065df4f3672c51422..654a2ef29e13238c48c4619437e5e426be1ec812 100644
--- a/app/views/users/edit_profile.html.erb
+++ b/app/views/users/edit_profile.html.erb
@@ -46,26 +46,29 @@
   <% end %>
   <div class="post-preview"></div>
 
-  <div class="grid">
-    <div class="grid--cell is-4 is-12-sm form-group">
-      <%= f.label :website, class: "form-element" %>
-      <span class="form-caption">A link to anywhere on the internet for your stuff.</span>
-      <%= f.text_field :website, class: 'form-element', autocomplete: 'off', placeholder: 'https://...' %>
-    </div>
-
-    <div class="grid--cell is-4 is-12-sm form-group">
-      <%= f.label :twitter, class: "form-element" %>
-      <span class="form-caption">Your Twitter username, if you've got one you want to share.</span>
-      <%= f.text_field :twitter, class: 'form-element', autocomplete: 'off', placeholder: '@username' %>
-    </div>
-
-    <div class="grid--cell is-4 is-12-sm form-group">
-      <%= f.label :discord, class: 'form-element' %>
-      <span class="form-caption">Your Discord user tag, <code>username</code> or <code>username#1234</code>.</span>
-      <%= f.text_field :discord, class: 'form-element', autocomplete: 'off', placeholder: 'username#1234' %>
+  <div>
+    <p>Extra fields -- your web site, GitHub profile, social-media usernames, whatever you want. Only values that begin with "http" are rendered as links.</p>
+    <div class="grid">
+      <%= f.fields_for :user_websites do |w| %>
+      <div class="grid grid--cell is-12 is-12-sm">
+        <div class="grid grid--cell is-3 is-3-sm">
+          <div class="grid--cell is-12"><%= w.text_field :label, class: 'form-element', autocomplete: 'off', placeholder: 'label' %></div>
+        </div>
+        <div class="grid grid--cell is-6 is-9-sm">
+          <div class="grid--cell is-12"><%= w.text_field :url, class: 'form-element', autocomplete: 'off', placeholder: 'https://...' %></div>
+        </div>
+      </div>
+      <% end %>
     </div>
   </div>
 
+  <div class="form-group has-padding-2">
+    <%= f.label :discord, class: 'form-element' %>
+    <span class="form-caption">Your Discord user tag, <code>username</code> or <code>username#1234</code>.</span>
+    <%= f.text_field :discord, class: 'form-element', autocomplete: 'off', placeholder: 'username#1234' %>
+  </div>
+    
+  
   <%= f.submit 'Save', class: 'button is-filled' %>
 <% end %>
 
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb
index f90c512959e455afd336000a74c25f989a6ce7f1..1ce3f9975a0ad04587cf86e7344b13a4f7091bee 100644
--- a/app/views/users/show.html.erb
+++ b/app/views/users/show.html.erb
@@ -24,39 +24,47 @@
   <div class="grid--cell is-9-lg is-12">
     <div class="h-p-0 h-p-t-0">
       <div class="profile-text">
-      <p>
-        <% if @user.website.present? %>
-  	  <% unless !user_signed_in? && !@user.community_user.privilege?('unrestricted') %>
-          <span class="h-m-r-4">
-            <i class="fas fa-link"></i>
-	    <%= link_to @user.website_domain, @user.website, rel: 'nofollow',
-                'aria-label': "Visit website of #{rtl_safe_username(@user)} at #{@user.website_domain}" %>
-          </span>
-          <% end %>
-        <% end %>
-        <% if @user.twitter.present? %>
-          <span class="h-m-r-4">
-            <i class="fab fa-twitter"></i> <%= link_to @user.twitter, "https://twitter.com/#{@user.twitter}",
-                                                       'aria-label': "Visit twitter account of #{rtl_safe_username(@user)}" %>
-          </span>
-        <% end %>
-        <% if @user.discord.present? %>
-          <span class="h-m-r-4">
-            <i class="fab fa-discord h-m-r-1"></i> <%= @user.discord %>
-          </span>
-        <% end %>
-      </p>
 
       <% effective_profile = raw(sanitize(@user.profile&.strip || '', scrubber: scrubber)) %>
       <% if effective_profile.blank? %>
         <p class="is-lead">A quiet enigma. We don't know anything about <span dir="ltr"><%= rtl_safe_username(@user) %></span> yet.</p>
-	<% elsif !user_signed_in? && !@user.community_user.privilege?('unrestricted') %>
-	  <%= sanitize(effective_profile, attributes: %w()) %>
-	<% else %>
+      <% elsif !user_signed_in? && !@user.community_user.privilege?('unrestricted') %>
+        <%= sanitize(effective_profile, attributes: %w()) %>
+      <% else %>
         <%= effective_profile %>
       <% end %>
       </div>
 
+      <% unless !user_signed_in? && !@user.community_user.privilege?('unrestricted') %>
+        <% if @user.valid_websites_for.size.positive? %>
+        <div>
+          <p><strong>Extra fields</strong></p>
+          <table class="table is-with-hover">
+            <% @user.valid_websites_for.each do |w| %>
+            <tr>
+              <td><%= w.label %></td>
+              <td>
+              <% if w.url[0,4] == 'http' %>
+                <%= link_to w.url, w.url, rel: 'nofollow' %>
+              <% else %>
+              <%= w.url %>
+              <% end %>
+              </td>
+            </tr>
+            <% end %>
+          </table>
+        </div>
+        <% end %>
+      <% end %>
+     
+      <p>
+        <% if @user.discord.present? %>
+          <span class="h-m-r-4">
+            <i class="fab fa-discord h-m-r-1"></i> <%= @user.discord %>
+          </span>
+        <% end %>
+      </p>
+
       <div class="button-list h-p-2">
         <% if user_signed_in? %>
           <%= link_to new_subscription_path(type: 'user', qualifier: @user.id, return_to: request.path), class: "button is-outlined is-small" do %>
@@ -83,7 +91,7 @@
             </div>
           </div>
         <% end %>
-        <% if current_user&.id == @user.id %>
+        <% if current_user&.same_as?(@user) %>
           <%= link_to qr_login_code_path, class: 'button is-outlined is-small' do %>
             <i class="fas fa-mobile-alt"></i> Mobile Sign In
           <% end %>
@@ -160,7 +168,7 @@
     <% if current_user&.id == @user.id || current_user&.is_moderator %>
     <table class="table is-full-width">
       <tr>
-	<td>User since <%= @user.created_at %></td>
+        <td>User since <%= @user.created_at %></td>
       </tr>
     </table>
     <% end %>
@@ -170,7 +178,7 @@
 
       <div class="widget">
         <% @abilities.each do |a| %>
-	<% if @user.privilege?(a.internal_id) %>
+        <% if @user.privilege?(a.internal_id) %>
           <div class="widget--body" title="<%= a.summary %>">
             <i class="fa-fw fas fa-<%= a.icon %>"></i>
             <a href="<%= ability_url(a.internal_id, for: @user.id) %>">
diff --git a/db/migrate/20250123141400_create_user_websites.rb b/db/migrate/20250123141400_create_user_websites.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ec77b2d17a97cf4e496cceec13d1c42f1203e52b
--- /dev/null
+++ b/db/migrate/20250123141400_create_user_websites.rb
@@ -0,0 +1,11 @@
+class CreateUserWebsites < ActiveRecord::Migration[7.0]
+  def change
+    create_table :user_websites do |t|
+      t.column :label, :string, limit:80
+      t.string :url
+      t.integer :position
+    end
+    add_reference :user_websites, :user, null: false, foreign_key: true
+    add_index(:user_websites, [:user_id, :url], unique: true)
+  end
+end
diff --git a/db/migrate/20250128030354_create_maintenance_tasks_runs.maintenance_tasks.rb b/db/migrate/20250128030354_create_maintenance_tasks_runs.maintenance_tasks.rb
new file mode 100644
index 0000000000000000000000000000000000000000..c9b215d687074236510e2d3114d15f510f8396cf
--- /dev/null
+++ b/db/migrate/20250128030354_create_maintenance_tasks_runs.maintenance_tasks.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+# This migration comes from maintenance_tasks (originally 20201211151756)
+class CreateMaintenanceTasksRuns < ActiveRecord::Migration[6.0]
+  def change
+    create_table(:maintenance_tasks_runs) do |t|
+      t.string(:task_name, null: false)
+      t.datetime(:started_at)
+      t.datetime(:ended_at)
+      t.float(:time_running, default: 0.0, null: false)
+      t.integer(:tick_count, default: 0, null: false)
+      t.integer(:tick_total)
+      t.string(:job_id)
+      t.bigint(:cursor)
+      t.string(:status, default: :enqueued, null: false)
+      t.string(:error_class)
+      t.string(:error_message)
+      t.text(:backtrace)
+      t.timestamps
+      t.index(:task_name)
+      t.index([:task_name, :created_at], order: { created_at: :desc })
+    end
+  end
+end
diff --git a/db/migrate/20250128030355_change_cursor_to_string.maintenance_tasks.rb b/db/migrate/20250128030355_change_cursor_to_string.maintenance_tasks.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4e414cad3ab30ce9e381cc685333fe910ebdd791
--- /dev/null
+++ b/db/migrate/20250128030355_change_cursor_to_string.maintenance_tasks.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+# This migration comes from maintenance_tasks (originally 20210219212931)
+class ChangeCursorToString < ActiveRecord::Migration[6.0]
+  # This migration will clear all existing data in the cursor column with MySQL.
+  # Ensure no Tasks are paused when this migration is deployed, or they will be resumed from the start.
+  # Running tasks are able to gracefully handle this change, even if interrupted.
+  def up
+    change_table(:maintenance_tasks_runs) do |t|
+      t.change(:cursor, :string)
+    end
+  end
+
+  def down
+    change_table(:maintenance_tasks_runs) do |t|
+      t.change(:cursor, :bigint)
+    end
+  end
+end
diff --git a/db/migrate/20250128030356_remove_index_on_task_name.maintenance_tasks.rb b/db/migrate/20250128030356_remove_index_on_task_name.maintenance_tasks.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1b5cadf8dfaa78a36e1913b4bef9ebe22b8cbcd5
--- /dev/null
+++ b/db/migrate/20250128030356_remove_index_on_task_name.maintenance_tasks.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+# This migration comes from maintenance_tasks (originally 20210225152418)
+class RemoveIndexOnTaskName < ActiveRecord::Migration[6.0]
+  def up
+    change_table(:maintenance_tasks_runs) do |t|
+      t.remove_index(:task_name)
+    end
+  end
+
+  def down
+    change_table(:maintenance_tasks_runs) do |t|
+      t.index(:task_name)
+    end
+  end
+end
diff --git a/db/migrate/20250128030357_add_arguments_to_maintenance_tasks_runs.maintenance_tasks.rb b/db/migrate/20250128030357_add_arguments_to_maintenance_tasks_runs.maintenance_tasks.rb
new file mode 100644
index 0000000000000000000000000000000000000000..8478268eba9ef048aa17cccfde5d32e89a405a39
--- /dev/null
+++ b/db/migrate/20250128030357_add_arguments_to_maintenance_tasks_runs.maintenance_tasks.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+# This migration comes from maintenance_tasks (originally 20210517131953)
+class AddArgumentsToMaintenanceTasksRuns < ActiveRecord::Migration[6.0]
+  def change
+    add_column(:maintenance_tasks_runs, :arguments, :text)
+  end
+end
diff --git a/db/migrate/20250128030358_add_lock_version_to_maintenance_tasks_runs.maintenance_tasks.rb b/db/migrate/20250128030358_add_lock_version_to_maintenance_tasks_runs.maintenance_tasks.rb
new file mode 100644
index 0000000000000000000000000000000000000000..b302565af387a4608bbb2f028cf2b621cadad33b
--- /dev/null
+++ b/db/migrate/20250128030358_add_lock_version_to_maintenance_tasks_runs.maintenance_tasks.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+# This migration comes from maintenance_tasks (originally 20211210152329)
+class AddLockVersionToMaintenanceTasksRuns < ActiveRecord::Migration[6.0]
+  def change
+    add_column(
+      :maintenance_tasks_runs,
+      :lock_version,
+      :integer,
+      default: 0,
+      null: false,
+    )
+  end
+end
diff --git a/db/migrate/20250128030359_change_runs_tick_columns_to_bigints.maintenance_tasks.rb b/db/migrate/20250128030359_change_runs_tick_columns_to_bigints.maintenance_tasks.rb
new file mode 100644
index 0000000000000000000000000000000000000000..064b07705736382731c14914e36124b65d18f1c4
--- /dev/null
+++ b/db/migrate/20250128030359_change_runs_tick_columns_to_bigints.maintenance_tasks.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+# This migration comes from maintenance_tasks (originally 20220706101937)
+class ChangeRunsTickColumnsToBigints < ActiveRecord::Migration[6.0]
+  def up
+    change_table(:maintenance_tasks_runs, bulk: true) do |t|
+      t.change(:tick_count, :bigint)
+      t.change(:tick_total, :bigint)
+    end
+  end
+
+  def down
+    change_table(:maintenance_tasks_runs, bulk: true) do |t|
+      t.change(:tick_count, :integer)
+      t.change(:tick_total, :integer)
+    end
+  end
+end
diff --git a/db/migrate/20250128030360_add_index_on_task_name_and_status_to_runs.maintenance_tasks.rb b/db/migrate/20250128030360_add_index_on_task_name_and_status_to_runs.maintenance_tasks.rb
new file mode 100644
index 0000000000000000000000000000000000000000..74d77e6bef74b226916e54d38e956497aaa6d22a
--- /dev/null
+++ b/db/migrate/20250128030360_add_index_on_task_name_and_status_to_runs.maintenance_tasks.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+# This migration comes from maintenance_tasks (originally 20220713131925)
+class AddIndexOnTaskNameAndStatusToRuns < ActiveRecord::Migration[6.0]
+  def change
+    remove_index(
+      :maintenance_tasks_runs,
+      column: [:task_name, :created_at],
+      order: { created_at: :desc },
+      name: :index_maintenance_tasks_runs_on_task_name_and_created_at,
+    )
+
+    add_index(
+      :maintenance_tasks_runs,
+      [:task_name, :status, :created_at],
+      name: :index_maintenance_tasks_runs,
+      order: { created_at: :desc },
+    )
+  end
+end
diff --git a/db/migrate/20250128030361_add_metadata_to_runs.maintenance_tasks.rb b/db/migrate/20250128030361_add_metadata_to_runs.maintenance_tasks.rb
new file mode 100644
index 0000000000000000000000000000000000000000..054ef0e2a976724eabe7a6b9126eed61d8efe280
--- /dev/null
+++ b/db/migrate/20250128030361_add_metadata_to_runs.maintenance_tasks.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+# This migration comes from maintenance_tasks (originally 20230622035229)
+class AddMetadataToRuns < ActiveRecord::Migration[6.0]
+  def change
+    add_column(:maintenance_tasks_runs, :metadata, :text)
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index d71e830457c3bcd9778c8b74686469193c65b7d4..97e097dbace5e8724a3be08dd2b560f59e5e1e8b 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema[7.0].define(version: 2024_10_20_193053) do
+ActiveRecord::Schema[7.0].define(version: 2025_01_28_030361) do
   create_table "abilities", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
     t.bigint "community_id"
     t.string "name"
@@ -309,6 +309,27 @@ ActiveRecord::Schema[7.0].define(version: 2024_10_20_193053) do
     t.index ["name"], name: "index_licenses_on_name"
   end
 
+  create_table "maintenance_tasks_runs", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
+    t.string "task_name", null: false
+    t.datetime "started_at", precision: nil
+    t.datetime "ended_at", precision: nil
+    t.float "time_running", default: 0.0, null: false
+    t.bigint "tick_count", default: 0, null: false
+    t.bigint "tick_total"
+    t.string "job_id"
+    t.string "cursor"
+    t.string "status", default: "enqueued", null: false
+    t.string "error_class"
+    t.string "error_message"
+    t.text "backtrace"
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.text "arguments"
+    t.integer "lock_version", default: 0, null: false
+    t.text "metadata"
+    t.index ["task_name", "status", "created_at"], name: "index_maintenance_tasks_runs", order: { created_at: :desc }
+  end
+
   create_table "micro_auth_apps", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
     t.string "name"
     t.string "app_id"
@@ -684,6 +705,15 @@ ActiveRecord::Schema[7.0].define(version: 2024_10_20_193053) do
     t.index ["community_user_id"], name: "index_user_abilities_on_community_user_id"
   end
 
+  create_table "user_websites", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
+    t.string "label", limit: 80
+    t.string "url"
+    t.integer "position"
+    t.bigint "user_id", null: false
+    t.index ["user_id", "url"], name: "index_user_websites_on_user_id_and_url", unique: true
+    t.index ["user_id"], name: "index_user_websites_on_user_id"
+  end
+
   create_table "users", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
     t.string "email"
     t.string "encrypted_password"
@@ -826,6 +856,7 @@ ActiveRecord::Schema[7.0].define(version: 2024_10_20_193053) do
   add_foreign_key "thread_followers", "posts"
   add_foreign_key "user_abilities", "abilities"
   add_foreign_key "user_abilities", "community_users"
+  add_foreign_key "user_websites", "users"
   add_foreign_key "users", "users", column: "deleted_by_id"
   add_foreign_key "votes", "communities"
   add_foreign_key "warning_templates", "communities"
diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb
index 17a18c257f006a6440b8bef65b7ceafe03255ad5..0aa5d08633d57e9b7556807f9b9cea9638159184 100644
--- a/test/controllers/users_controller_test.rb
+++ b/test/controllers/users_controller_test.rb
@@ -113,16 +113,41 @@ class UsersControllerTest < ActionController::TestCase
     assert_response 200
   end
 
-  test 'should update profile text' do
+  test 'should redirect & show success notice on profile update' do
     sign_in users(:standard_user)
-    patch :update_profile, params: { user: { profile_markdown: 'ABCDEF GHIJKL', website: 'https://example.com/user',
-                                             twitter: '@standard_user' } }
+    patch :update_profile, params: { user: { username: 'std' } }
     assert_response 302
     assert_not_nil flash[:success]
     assert_not_nil assigns(:user)
     assert_equal users(:standard_user).id, assigns(:user).id
-    assert_not_nil assigns(:user).profile
-    assert_equal 'standard_user', assigns(:user).twitter
+  end
+
+  test 'should update profile text' do
+    sign_in users(:standard_user)
+    patch :update_profile, params: {
+      user: { profile_markdown: 'ABCDEF GHIJKL' }
+    }
+    assert_equal assigns(:user).profile.strip, '<p>ABCDEF GHIJKL</p>'
+  end
+
+  test 'should update websites' do
+    sign_in users(:standard_user)
+    patch :update_profile, params: {
+      user: { user_websites_attributes: {
+        '0': { label: 'web', url: 'example.com' }
+      } }
+    }
+    assert_not_nil assigns(:user).user_websites
+    assert_equal 'web', assigns(:user).user_websites.first.label
+    assert_equal 'example.com', assigns(:user).user_websites.first.url
+  end
+
+  test 'should update user discord link' do
+    sign_in users(:standard_user)
+    patch :update_profile, params: {
+      user: { discord: 'example_user#1234' }
+    }
+    assert_equal 'example_user#1234', assigns(:user).discord
   end
 
   test 'should get full posts list for a user' do