Repository: manyfold3d/manyfold
Pull request #3961: User quota feature
Author: login: matthewbadeau
Labels: name: feature
description: User-facing features and product enhancements
Created: 2025-04-09T13:17:31Z | Merged: 2025-04-22T12:09:45Z

## Checklist

🚨 Please review the [guidelines for contributing](../CONTRIBUTING.md) to this repository. 🚨

- [x] Make sure you are making a pull request against our **main branch** (left side)
- [x] Check that that your branch is up to date with our main.
- [x] Make sure you are requesting to **pull a topic/feature/bugfix branch** (right side). Don't request *your* main!
- [x] Check that the tests and code linter both pass.
- [x] If you're a new contributor, please sign our [contributor license agreement](https://cla-assistant.io/manyfold3d/manyfold).

## Warnings

- [x] This PR will change existing database contents.
- [ ] This PR introduces a breaking change to existing installations.

## Summary

This feature adds user quotas.

* Site wide quota feature enables/disables quotas
* Site wide quota can be configured
* User-specific settings can be overridden to use site-wide user quota
* User-specific quota size can be configured
* A value of `0` is considered Unlimited (no quota)
* Setting a quota value is the value in MB

## Linked issues

#3437 

## Description of changes

* Users controller changed to set a default quota on creation. This ensures that users always get a quota. Without this, users on creation would get the DB's default value (now set to 1)
* Settings controller changed to allow new site-wide settings. Default quota size is the size from the form multiplied by 1MB
* Settings model adds new fields
* User model adds the following:
  * `#set_quota` before save method to assign either a new value for the `quota` attribute or the site's default value
  * New `:quota` and `:quota_use_site_default` attributes
  * `#quota` to add some pre-processing of the value before saving to the DB and when retreiving
  * `#has_quota?` yields a true/false value based on the `:quota` attribute
  * `#current_space_used` sums the models on disk where the permissions are `"own"`
* Changes upload policy to allow/disallow uploads based on current quota
* Adds fields to multi-user view (Multiuser) to edit site wide settings
* Adds fields to user view (Manage Users)
* Shows current used size and quota values in user view
* Adds i18n strings
* Adds migrations for new user attributes
* Adds specs for user model to test for quota size and `#has_quota?` logic



Diff:
diff --git a/app/controllers/settings/users_controller.rb b/app/controllers/settings/users_controller.rb
index 04f57b530..8b3c39e0e 100644
--- a/app/controllers/settings/users_controller.rb
+++ b/app/controllers/settings/users_controller.rb
@@ -28,7 +28,9 @@ def create
     # Create user with a random password if one isn't provided
     @user = User.create({
       "password" => password,
-      "password_confirmation" => password
+      "password_confirmation" => password,
+      "quota" => SiteSettings.default_user_quota,
+      "quota_use_site_default" => true
     }.merge(user_params))
     if @user.valid?
       @user.send_reset_password_instructions if SiteSettings.email_configured?
@@ -71,6 +73,8 @@ def user_params
       :username,
       :password,
       :password_confirmation,
+      :quota,
+      :quota_use_site_default,
       role_ids: []
     )
   end
diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb
index 6709b875a..6fb13dc0c 100644
--- a/app/controllers/settings_controller.rb
+++ b/app/controllers/settings_controller.rb
@@ -64,6 +64,8 @@ def update_multiuser_settings(settings)
     SiteSettings.registration_enabled = (settings[:registration_open])
     SiteSettings.approve_signups = (settings[:approve_signups])
     SiteSettings.default_viewer_role = (settings[:default_viewer_role].presence)
+    SiteSettings.enable_user_quota = (settings[:enable_user_quota].presence)
+    SiteSettings.default_user_quota = (settings[:default_user_quota].to_i * 1.megabyte)
   end
 
   def update_usage_settings(settings)
diff --git a/app/models/site_settings.rb b/app/models/site_settings.rb
index a6c26d454..4ebc98057 100644
--- a/app/models/site_settings.rb
+++ b/app/models/site_settings.rb
@@ -31,6 +31,9 @@ class SiteSettings < RailsSettings::Base
   field :rules, type: :string, default: nil
   field :support_link, type: :string, default: nil
 
+  field :enable_user_quota, type: :boolean, default: false
+  field :default_user_quota, type: :integer, default: 0
+
   validates :model_ignored_files, regex_array: {strict: true}
 
   def self.email_configured?
diff --git a/app/models/user.rb b/app/models/user.rb
index f14a3a9d7..f30519dff 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -6,6 +6,8 @@ class User < ApplicationRecord
   include CaberSubject
   include PublicIDable
 
+  before_save :set_quota
+
   acts_as_federails_actor(
     username_field: :username,
     name_field: :username,
@@ -168,8 +170,25 @@ def public?
     true
   end
 
+  # Quota is in MB and is referred to in the UI as file storage limits for clarity
+  def quota
+    quota_use_site_default ? SiteSettings.default_user_quota : attributes["quota"].to_i * 1.megabyte
+  end
+
+  def has_quota?
+    !(attributes["quota"] == 0) && SiteSettings.enable_user_quota
+  end
+
+  def current_space_used
+    permitted_models.with_permission("own").sum(&:size_on_disk)
+  end
+
   private
 
+  def set_quota
+    attributes["quota"] = SiteSettings.default_user_quota if quota_use_site_default
+  end
+
   def has_any_role_of?(*args)
     args.map { |it| has_role? it }.any?
   end
diff --git a/app/policies/upload_policy.rb b/app/policies/upload_policy.rb
index 6a91d56ca..c884c6c68 100644
--- a/app/policies/upload_policy.rb
+++ b/app/policies/upload_policy.rb
@@ -6,6 +6,7 @@ def index?
   def create?
     all_of(
       user&.is_contributor?,
+      user&.has_quota? ? user.current_space_used < user.quota : true,
       none_of(
         SiteSettings.demo_mode_enabled?
       )
diff --git a/app/views/settings/multiuser.html.erb b/app/views/settings/multiuser.html.erb
index 9cfee1035..4637b882a 100644
--- a/app/views/settings/multiuser.html.erb
+++ b/app/views/settings/multiuser.html.erb
@@ -55,5 +55,20 @@
       <small><%= t(".default_viewer_role.help") %></small>
     </div>
   </div>
+  <hr>
+
+  <h3><%= t(".user_quotas") %></h3>
+  <div class="row">
+    <%= form.label nil, t(".user_quotas_enable_quota"), for: "multiuser[enable_user_quota]", class: "col-sm-4 col-form-label" %>
+    <div class="col-sm-8">
+      <%= form.check_box "multiuser[enable_user_quota]", checked: SiteSettings.enable_user_quota, class: "form-check-input" %>
+    </div>
+  </div>
+  <div class="row">
+    <%= form.label nil, t(".user_quotas_site_default"), for: "multiuser[default_user_quota]", class: "col-sm-4 col-form-label" %>
+    <div class="col-sm-8">
+      <%= form.number_field "multiuser[default_user_quota]", min: 0, step: 1, value: (SiteSettings.default_user_quota.to_i / 1.megabyte) %>
+    </div>
+  </div>
   <%= render "submit", form: form %>
 <% end %>
diff --git a/app/views/settings/users/_form.html.erb b/app/views/settings/users/_form.html.erb
index c53b59282..9ab795bc6 100644
--- a/app/views/settings/users/_form.html.erb
+++ b/app/views/settings/users/_form.html.erb
@@ -28,6 +28,22 @@
     </div>
   </div>
 
+  <% if SiteSettings.enable_user_quota %>
+    <h3><%= t("settings.users.quota.title") %></h3>
+    <div class="row">
+      <%= form.label nil, t("settings.users.quota.using_site_default"), class: "col-sm-4 col-form-label" %>
+      <div class="col-sm-8">
+        <%= form.check_box "quota_use_site_default", value: :quota_use_site_default, class: "form-check-input" %>
+      </div>
+    </div>
+    <div class="row">
+      <%= form.label nil, t("settings.users.quota.current_quota"), class: "col-sm-4 col-form-label" %>
+      <div class="col-sm-8">
+        <%= form.number_field "quota", min: 0, step: 1, value: (user.quota.to_i / 1.megabyte) %>
+      </div>
+    </div>
+  <% end %>
+
   <%= form.submit translate(".submit"), class: "btn btn-primary" %>
 
 <% end %>
diff --git a/app/views/settings/users/show.html.erb b/app/views/settings/users/show.html.erb
index 7205770d2..093e29ed7 100644
--- a/app/views/settings/users/show.html.erb
+++ b/app/views/settings/users/show.html.erb
@@ -24,6 +24,20 @@
     <th><%= User.human_attribute_name(:updated_at) %></th>
     <td><%= @user.updated_at.to_fs(:long) %></td>
   </tr>
+  <tr>
+    <th><%= t("settings.users.quota.current_space_used") %></th>
+    <td><%= @user.current_space_used.to_fs(:human_size, precision: 2) %></td>
+  </tr>
+  <% if SiteSettings.enable_user_quota %>
+    <tr>
+      <th><%= t("settings.users.quota.using_site_default") %></th>
+      <td><%= @user.quota_use_site_default? ? "✅" : "❌" %></td>
+    </tr>
+    <tr>
+      <th><%= t("settings.users.quota.title") %></th>
+      <td><%= @user.has_quota? ? @user.quota.to_fs(:human_size) : t("settings.users.quota.unlimited_quota") %></td>
+    </tr>
+  <% end %>
   <tr>
     <th><%= User.human_attribute_name(:interface_language) %></th>
     <td><%= @user.interface_language.present? ? I18nData.languages(@user.interface_language)[@user.interface_language.to_s]&.capitalize : t("devise.registrations.general_settings.interface_language.autodetect") %></td>
diff --git a/config/locales/settings/en.yml b/config/locales/settings/en.yml
index 5888ffce7..da4363b15 100644
--- a/config/locales/settings/en.yml
+++ b/config/locales/settings/en.yml
@@ -94,6 +94,9 @@ en:
         help: Lets visitors sign up for accounts.
         label: Enable signup
       single_user_mode: Manyfold is running in single user mode.
+      user_quotas: User upload size limit
+      user_quotas_enable_quota: Enable user upload storage limit
+      user_quotas_site_default: Site default user upload storage limit in MB
     reporting:
       description_html: If you enable usage tracking, the following information will be sent once a day to <code>%{endpoint}</code>. The <code>id</code> is randomly generated when you enable tracking.
       heading: Usage Tracking
@@ -150,6 +153,12 @@ en:
         view: View
       new:
         title: Create user
+      quota:
+        current_quota: Current file storage limit in MB
+        current_space_used: Currently used file storage
+        title: File storage limit
+        unlimited_quota: Unlimited
+        using_site_default: Using site default file storage limit?
       show:
         approve: Approve user
         confirm_destroy: Are you sure you want to remove this user account immediately? This cannot be undone!
diff --git a/db/migrate/20250409125753_add_quota_to_users.rb b/db/migrate/20250409125753_add_quota_to_users.rb
new file mode 100644
index 000000000..e8d3bafe8
--- /dev/null
+++ b/db/migrate/20250409125753_add_quota_to_users.rb
@@ -0,0 +1,6 @@
+class AddQuotaToUsers < ActiveRecord::Migration[7.2]
+  def change
+    add_column :users, :quota, :integer, default: 1, null: false
+    add_column :users, :quota_use_site_default, :boolean, default: true, null: false
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index ee3b19b32..b817e0ffa 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.2].define(version: 2025_04_09_120155) do
+ActiveRecord::Schema[7.2].define(version: 2025_04_09_125753) do
   create_table "caber_relations", force: :cascade do |t|
     t.string "subject_type"
     t.integer "subject_id"
@@ -386,6 +386,8 @@
     t.string "sensitive_content_handling"
     t.string "public_id"
     t.boolean "approved", default: true, null: false
+    t.integer "quota", default: 1, null: false
+    t.boolean "quota_use_site_default", default: true, null: false
     t.index ["approved"], name: "index_users_on_approved"
     t.index ["email"], name: "index_users_on_email", unique: true
     t.index ["public_id"], name: "index_users_on_public_id"
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index dc1641535..dd6b9ed94 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -42,6 +42,27 @@
     expect(u).to have_role(:member)
   end
 
+  it "enables quotas" do
+    SiteSettings.enable_user_quota = true
+    SiteSettings.default_user_quota = 100
+    expect(build(:user).has_quota?).to be_truthy # rubocop:disable RSpec/PredicateMatcher
+  end
+
+  it "assigned site quota not explicitly defined" do
+    expect(build(:user).quota).to eq SiteSettings.default_user_quota
+  end
+
+  it "uses site default quota" do
+    SiteSettings.default_user_quota = 100
+    user = create(:user, quota: 42, quota_use_site_default: true)
+    expect(user.quota).to eq SiteSettings.default_user_quota
+  end
+
+  it "considers zero quota value as unlimited" do
+    user = create(:user, quota: 0, quota_use_site_default: false)
+    expect(user.has_quota?).to be_falsey # rubocop:disable RSpec/PredicateMatcher
+  end
+
   context "with omniauth" do
     let(:auth_data) do
       OpenStruct.new({


Reviews:
user: login: Floppy
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-04-09T14:55:08Z
user: login: matthewbadeau
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T10:27:42Z
user: login: matthewbadeau
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T10:33:49Z
user: login: Floppy
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T15:11:20Z
user: login: matthewbadeau
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T09:20:38Z
user: login: matthewbadeau
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T10:38:13Z
user: login: Floppy
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T11:13:13Z
user: login: matthewbadeau
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T11:30:16Z
user: login: Floppy
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-04-22T07:54:50Z
user: login: matthewbadeau
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-22T09:02:24Z
user: login: matthewbadeau
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-22T09:04:34Z
user: login: Floppy
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-22T12:06:44Z

Review comments:
diff_hunk: @@ -1 +1 @@
-DataMigrate::Data.define(version: 20250121164452)
+DataMigrate::Data.define()
path: db/data_schema.rb
user: login: Floppy
body: Can you revert this? I don't know why it's started making the change, I always have to remeber to ignore it and not commit it when there's a database update
created_at: 2025-04-09T14:53:42Z
author_association: COLLABORATOR
diff_hunk: @@ -62,6 +64,9 @@ class User < ApplicationRecord
     dependent: :delete_all,
     inverse_of: :owner
 
+  attribute :quota_use_site_default, :boolean, default: true
path: app/models/user.rb
user: login: Floppy
body: could this be simplified by just having the user quota field set to nil by default, which means "use site default"? Then this boolean wouldn't be needed?
created_at: 2025-04-09T14:54:51Z
author_association: COLLABORATOR
diff_hunk: @@ -1 +1 @@
-DataMigrate::Data.define(version: 20250121164452)
+DataMigrate::Data.define()
path: db/data_schema.rb
user: login: matthewbadeau
body: Ah, OK. I looked through the [data-migrate gem README](https://github.com/ilyakatz/data-migrate?tab=readme-ov-file#rake-tasks) and I'm guessing it does this when I run a `db:migrate` but don't run `data:migrate`. I ran a `data:migrate` now, so it should be reverted on the next push.

edit: reading through it more. Next time I'll run `db:migrate:with_data` and I think this will get updated with a valid value.
created_at: 2025-04-10T10:27:42Z
author_association: COLLABORATOR
diff_hunk: @@ -62,6 +64,9 @@ class User < ApplicationRecord
     dependent: :delete_all,
     inverse_of: :owner
 
+  attribute :quota_use_site_default, :boolean, default: true
path: app/models/user.rb
user: login: matthewbadeau
body: Were you talking about the `attribute :quota, :integer, default: 0` field? There's a DB constraint to set this as an integer with default 0. When I tried setting `attribute :quota, :integer, default: nil`, it fails because of the constraint.

This reminds me that there should be a test for this as well. I'm adding that test.
created_at: 2025-04-10T10:33:49Z
author_association: COLLABORATOR
diff_hunk: @@ -62,6 +64,9 @@ class User < ApplicationRecord
     dependent: :delete_all,
     inverse_of: :owner
 
+  attribute :quota_use_site_default, :boolean, default: true
path: app/models/user.rb
user: login: Floppy
body: I've put this comment in the wrong place really. If you change the migration line to: `add_column :users, :quota, :integer, null: true`, it should then allow nil values.
created_at: 2025-04-10T15:11:20Z
author_association: COLLABORATOR
diff_hunk: @@ -62,6 +64,9 @@ class User < ApplicationRecord
     dependent: :delete_all,
     inverse_of: :owner
 
+  attribute :quota_use_site_default, :boolean, default: true
path: app/models/user.rb
user: login: matthewbadeau
body: I've applied this suggestion. I've had to move the code around a bit for readability outside the scope of this MR, so I'll mark this one resolved.
created_at: 2025-04-15T09:20:38Z
author_association: COLLABORATOR
diff_hunk: @@ -62,6 +64,9 @@ class User < ApplicationRecord
     dependent: :delete_all,
     inverse_of: :owner
 
+  attribute :quota_use_site_default, :boolean, default: true
path: app/models/user.rb
user: login: matthewbadeau
body: AHA! I just realized what you meant. `:quota_use_site_default` is actually making an exception to the site policy. By defining `:quota_use_site_default` as false, admins can set a higher or lower quota than the site default. So this should exist and I can't check for just `:quota` being nil as I still need that value. 😄 
created_at: 2025-04-15T10:38:13Z
author_association: COLLABORATOR
diff_hunk: @@ -62,6 +64,9 @@ class User < ApplicationRecord
     dependent: :delete_all,
     inverse_of: :owner
 
+  attribute :quota_use_site_default, :boolean, default: true
path: app/models/user.rb
user: login: Floppy
body: Yeah, I was thinking that `quota: nil` could mean "use site policy", but you've put more thought into this than me :)
created_at: 2025-04-15T11:13:13Z
author_association: COLLABORATOR
diff_hunk: @@ -62,6 +64,9 @@ class User < ApplicationRecord
     dependent: :delete_all,
     inverse_of: :owner
 
+  attribute :quota_use_site_default, :boolean, default: true
path: app/models/user.rb
user: login: matthewbadeau
body: I'm going to update the description with all changes and my thinking behind certain attributes. This thread can be resolved. There will be lots more to review soon 😸 
created_at: 2025-04-16T11:30:16Z
author_association: COLLABORATOR
diff_hunk: @@ -10,6 +10,7 @@
     <%= content_tag :th, t(".local") if SiteSettings.federation_enabled? %>
     <%= content_tag :th, t(".fediverse_address") if SiteSettings.federation_enabled? %>
     <th><%= User.human_attribute_name(:roles) %></th>
+    <th><%= t(".quota.title") %></th>
path: app/views/settings/users/index.html.erb
user: login: Floppy
body: There doesn't seem to be a corresponding column the the table below for this heading?
created_at: 2025-04-22T07:52:26Z
author_association: COLLABORATOR
diff_hunk: @@ -46,6 +48,9 @@ class User < ApplicationRecord
   attribute :problem_settings, :json
   attribute :file_list_settings, :json
 
+  attribute :quota_use_site_default, :boolean, default: true
+  attribute :quota, :integer
path: app/models/user.rb
user: login: Floppy
body: I don't think these attribute lines are required; the ones above are only because we needed to provide a JSON hint to MariaDB; these will be fine without.
created_at: 2025-04-22T07:54:10Z
author_association: COLLABORATOR
diff_hunk: @@ -10,6 +10,7 @@
     <%= content_tag :th, t(".local") if SiteSettings.federation_enabled? %>
     <%= content_tag :th, t(".fediverse_address") if SiteSettings.federation_enabled? %>
     <th><%= User.human_attribute_name(:roles) %></th>
+    <th><%= t(".quota.title") %></th>
path: app/views/settings/users/index.html.erb
user: login: matthewbadeau
body: Ah, sorry, I meant to remove that. I was hoping to do something like this but found its UX to be confusing without highlighted columns. Plus I found it was adding too much info that's not necessary at this point.
![Screenshot 2025-04-22 at 17 59 18](https://github.com/user-attachments/assets/2032e63a-9d94-4279-8603-c10c7764c178)


Instead, I deleted those columns. Plus, this was before I put in a `SiteSettings.enable_user_quota` setting.

This would have been:

```erb
    <% if SiteSettings.enable_user_quota %>
      <th><%= t(".quota.current_space_used") %></th>
      <th><%= t(".quota.current_quota") %></th>
    <% end %>
```
and this below in the rows:
```erb
      <% if SiteSettings.enable_user_quota %>
        <td><%= user.entity.current_space_used.to_fs(:human_size) %></td>
        <td><%= user.entity.quota.to_fs(:human_size) %></td>
      <% end %>
```


I will remove this line.

```suggestion
```
created_at: 2025-04-22T09:02:24Z
author_association: COLLABORATOR
diff_hunk: @@ -46,6 +48,9 @@ class User < ApplicationRecord
   attribute :problem_settings, :json
   attribute :file_list_settings, :json
 
+  attribute :quota_use_site_default, :boolean, default: true
+  attribute :quota, :integer
path: app/models/user.rb
user: login: matthewbadeau
body: Ah, now I understand. I'll remove them.
created_at: 2025-04-22T09:04:34Z
author_association: COLLABORATOR

PR comments:
user: login: matthewbadeau
created_at: 2025-04-15T09:24:08Z
body: Still on my todo list:

- [x] Rework UI to show "in MiB" for all fields
- [x] Add user views to adjust user-specific quotas
- [x] Set upload policy to consider user quota during upload
- [x] `has_quota?` logic no longer works
author_association: COLLABORATOR
user: login: codacy-production[bot]
created_at: 2025-04-22T09:10:27Z
body: ## Coverage summary from Codacy
### [See diff coverage on Codacy](https://app.codacy.com/gh/manyfold3d/manyfold/pull-requests/3961/coverage?utm_source=github.com&utm_medium=coverageSummary&utm_campaign=coverageSummaryPullRequest)

 | Coverage variation | Diff coverage | 
 | ------------- | ------------- |
 | :white_check_mark: **+0.00%** (target: -1.00%) | :white_check_mark: **85.71%** |

<details>
<summary>Coverage variation details</summary>

| | Coverable lines | Covered lines | Coverage |
| ------------- | ------------- | ------------- | ------------- |
| Common ancestor commit (69eb73179c6751438bce44ba8652c5c5347c00a1) | 3771 | 3196 | 84.75% | |
| Head commit (6e3329aba65d9e9d423955624dbda1f1544fb38a) | 3785 (+14) | 3208 (+12) | 84.76% (**+0.00%**) |

**Coverage variation** is the difference between the coverage for the head and common ancestor commits of the pull request branch: `<coverage of head commit> - <coverage of common ancestor commit>`
</details>

<details>
<summary>Diff coverage details</summary>

| | Coverable lines | Covered lines | Diff coverage |
| ------------- | ------------- | ------------- | ------------- |
| Pull request (#3961) | 14 | 12 | **85.71%** |

**Diff coverage** is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: `<covered lines added or modified>/<coverable lines added or modified> * 100%`
</details>

#### [See your quality gate settings](https://app.codacy.com/gh/manyfold3d/manyfold/settings/quality?utm_source=github.com&utm_medium=coverageSummary&utm_campaign=coverageSummaryGates)&nbsp;&nbsp;&nbsp;&nbsp;[Change summary preferences](https://app.codacy.com/gh/manyfold3d/manyfold/settings/integrations?utm_source=github.com&utm_medium=coverageSummary&utm_campaign=coverageSummarySettings)

<!-- 108f945d-e553-4918-8ccf-027c6d4acc2c -->
author_association: NONE
user: login: Floppy
created_at: 2025-04-22T12:09:39Z
body: ![ship it!](https://media3.giphy.com/media/v1.Y2lkPTc5MGI3NjExdnU3eGk4NWt2ODVkcXR5YnhlZnRveDEyYTBxaXRscnJ3YndxZDF3ZSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/143vPc6b08locw/giphy.gif)
author_association: COLLABORATOR