+
Connect to Basecamp to auto-preview links?
+
If you want links from Basecamp to use the title of the item "important message" rather than just the URL "http://basecamp.com/1234", connect your Basecamp account to Fizzy.
+
In the next step you'll sign in to Basecamp and authorize the connection to Fizzy.
+
+ <%= button_to "Yes, continue…", basecamp_integration_path, method: :post, data: { turbo: false }, class: "btn btn--link" %>
+ Never mind
+
+
diff --git a/config/brakeman.ignore b/config/brakeman.ignore
index 6c99b817b6..0209c3033a 100644
--- a/config/brakeman.ignore
+++ b/config/brakeman.ignore
@@ -46,6 +46,29 @@
],
"note": ""
},
+ {
+ "warning_type": "Redirect",
+ "warning_code": 18,
+ "fingerprint": "a8fc3f4864b089c35f52d7d5607a60f70aedb99ba46b9b0f2ca41ddc8386a03c",
+ "check_name": "Redirect",
+ "message": "Possible unprotected redirect",
+ "file": "app/controllers/integrations/basecamps_controller.rb",
+ "line": 9,
+ "link": "https://brakemanscanner.org/docs/warning_types/redirect/",
+ "code": "redirect_to(Integration::Basecamp.find_or_create_by(:owner => Current.user).authorization_url, :allow_other_host => true)",
+ "render_path": null,
+ "location": {
+ "type": "method",
+ "class": "Integrations::BasecampsController",
+ "method": "create"
+ },
+ "user_input": "Integration::Basecamp.find_or_create_by(:owner => Current.user).authorization_url",
+ "confidence": "Weak",
+ "cwe_id": [
+ 601
+ ],
+ "note": ""
+ },
{
"warning_type": "Mass Assignment",
"warning_code": 70,
diff --git a/config/importmap.rb b/config/importmap.rb
index 6988153410..b8179b189c 100644
--- a/config/importmap.rb
+++ b/config/importmap.rb
@@ -9,6 +9,7 @@
pin_all_from "app/javascript/controllers", under: "controllers"
pin_all_from "app/javascript/helpers", under: "helpers"
pin_all_from "app/javascript/initializers", under: "initializers"
+pin_all_from "app/javascript/models", under: "models"
pin "marked" # @15.0.11
pin "lexxy"
pin "@rails/activestorage", to: "activestorage.esm.js"
diff --git a/config/routes.rb b/config/routes.rb
index bbce4fe01d..78232d3ba5 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -186,6 +186,8 @@
end
end
+ resource :unfurl_link, only: :create
+
namespace :public do
resources :boards do
scope module: :boards do
diff --git a/db/migrate/20251202144419_create_integrations.rb b/db/migrate/20251202144419_create_integrations.rb
new file mode 100644
index 0000000000..9eed74e091
--- /dev/null
+++ b/db/migrate/20251202144419_create_integrations.rb
@@ -0,0 +1,11 @@
+class CreateIntegrations < ActiveRecord::Migration[8.1]
+ def change
+ create_table :integrations do |t|
+ t.text :data
+ t.string :type
+ t.belongs_to :owner, null: false, foreign_key: false
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index b84d3c1603..8a9bf2b102 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[8.2].define(version: 2025_12_01_100607) do
+ActiveRecord::Schema[8.2].define(version: 2025_12_02_144419) do
create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "account_id", null: false
@@ -321,6 +321,15 @@
t.index ["email_address"], name: "index_identities_on_email_address", unique: true
end
+ create_table "integrations", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.text "data"
+ t.bigint "owner_id", null: false
+ t.string "type"
+ t.datetime "updated_at", null: false
+ t.index ["owner_id"], name: "index_integrations_on_owner_id"
+ end
+
create_table "magic_links", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "code", null: false
t.datetime "created_at", null: false
diff --git a/lib/network_guard.rb b/lib/network_guard.rb
new file mode 100644
index 0000000000..ccb6ecc56c
--- /dev/null
+++ b/lib/network_guard.rb
@@ -0,0 +1,40 @@
+module NetworkGuard
+ class RestrictedHostError < StandardError; end
+
+ extend self
+
+ RESTRICTED_IP_RANGES = [
+ # IPv4 mapped to IPv6
+ IPAddr.new("::ffff:0:0/96"),
+ # Broadcasts
+ IPAddr.new("0.0.0.0/8")
+ ].freeze
+
+ def restricted_host?(hostname, **options)
+ resolve(hostname, **options).any?
+ rescue RestrictedHostError
+ false
+ end
+
+ def resolve(hostname, timeout: nil)
+ ip_addresses = []
+
+ Resolv::DNS.open(timeouts: timeout) do |dns|
+ dns.each_address(hostname) do |ip_address|
+ ip_addresses << IPAddr.new(ip_address)
+ end
+ end
+
+ if ip_addresses.any? { |ip_address| restricted_ip_address?(ip_address) }
+ raise RestrictedHostError
+ else
+ ip_addresses
+ end
+ end
+
+ def restricted_ip_address?(ip_address)
+ ip_address.private? ||
+ ip_address.loopback? ||
+ DISALLOWED_IP_RANGES.any? { |range| range.include?(ip_address) }
+ end
+end
diff --git a/test/fixtures/integrations.yml b/test/fixtures/integrations.yml
new file mode 100644
index 0000000000..deaa98d2b3
--- /dev/null
+++ b/test/fixtures/integrations.yml
@@ -0,0 +1,7 @@
+kevins_basecamp:
+ type: "Integration::Basecamp"
+ owner: kevin
+ data: '<%= {
+ access_token: "BAhbB0kiAjoBeyJjbGllbnRfaWQiOiI5ZDJmZjU2NDM4YTg1MTNjMjAwNDQ3NTJkMWYzNjE2ZTA3NmUwYTU3IiwiZXhwaXJlc19hdCI6IjIwMjUtMTAtMDZUMTM6MjM6NDlaIiwidXNlcl9pZHMiOlsyNjIxOTgyNTAsMTI2NTEwMDgzLDk0OTc3OTE2NSw5NTA3NTg3OSw4NjgyMTE0NDEsNzA3NTQwNDE1LDIwOTUwMDc0Miw2NDUzOTU2OTAsODc5OTczNzU2LDk3NjAyNDY4LDkwMDk4Nzc1OSwxMTg0NzE1MDQsMjQ5MTUxMjgzLDQwMDE1OTMwNCw4MDk5MDk3Nl0sInZlcnNpb24iOjEsImFwaV9kZWFkYm9sdCI6ImRjOTUzYmYwMDdmMTQyNGY0YWYyN2FjMzI0NGYyZjYzIn0GOgZFVEl1OglUaW1lDc1kH8CpNxZfCToNbmFub19udW1pAjICOg1uYW5vX2RlbmkGOg1zdWJtaWNybyIHViA6CXpvbmVJIghVVEMGOwBG--56891137f381109fc1c075ca95d871b529040fba",
+ refresh_token: "BAhbB0kiAjoBeyJjbGllbnRfaWQiOiI5ZDJmZjU2NDM4YTg1MTNjMjAwNDQ3NTJkMWYzNjE2ZTA3NmUwYTU3IiwiZXhwaXJlc19hdCI6IjIwMzUtMDktMjJUMTM6MjM6NDlaIiwidXNlcl9pZHMiOlsyNjIxOTgyNTAsMTI2NTEwMDgzLDk0OTc3OTE2NSw5NTA3NTg3OSw4NjgyMTE0NDEsNzA3NTQwNDE1LDIwOTUwMDc0Miw2NDUzOTU2OTAsODc5OTczNzU2LDk3NjAyNDY4LDkwMDk4Nzc1OSwxMTg0NzE1MDQsMjQ5MTUxMjgzLDQwMDE1OTMwNCw4MDk5MDk3Nl0sInZlcnNpb24iOjEsImFwaV9kZWFkYm9sdCI6ImRjOTUzYmYwMDdmMTQyNGY0YWYyN2FjMzI0NGYyZjYzIn0GOgZFVEl1OglUaW1lDc3iIcAlWBZfCToNbmFub19udW1pAX46DW5hbm9fZGVuaQY6DXN1Ym1pY3JvIgcSYDoJem9uZUkiCFVUQwY7AEY=--c94d9dee4fe6d86c5f30cc1b6b2352c142676557"
+ }.to_json %>'
diff --git a/test/models/link/fetch_test.rb b/test/models/link/fetch_test.rb
new file mode 100644
index 0000000000..acf6ee84a6
--- /dev/null
+++ b/test/models/link/fetch_test.rb
@@ -0,0 +1,69 @@
+require "test_helper"
+
+class Link::FetchTest < ActiveSupport::TestCase
+ test "http_url?" do
+ fetch = Link::Fetch.new("https://example.com/page")
+ assert fetch.http_url?
+
+ non_http_fetch = Link::Fetch.new("ftp://example.com/file")
+ assert_not non_http_fetch.http_url?
+ end
+
+ test "html_content?" do
+ fetch = Link::Fetch.new("https://example.com/page")
+
+ stub_request(:head, "https://example.com/page")
+ .to_return(status: 200, headers: { "Content-Type" => "text/html; charset=utf-8" })
+
+ assert fetch.html_content?
+
+ stub_request(:head, "https://example.com/image")
+ .to_return(status: 200, headers: { "Content-Type" => "image/jpeg" })
+
+ image_fetch = Link::Fetch.new("https://example.com/image")
+ assert_not image_fetch.html_content?
+ end
+
+ test "content_type" do
+ fetch = Link::Fetch.new("https://example.com/page")
+
+ stub_request(:head, "https://example.com/page")
+ .to_return(status: 200, headers: { "Content-Type" => "text/html; charset=utf-8" })
+
+ assert_equal "text/html; charset=utf-8", fetch.content_type
+
+ stub_request(:head, "https://example.com/error")
+ .to_return(status: 404)
+
+ error_fetch = Link::Fetch.new("https://example.com/error")
+ assert_raises(Link::Fetch::UnsuccesfulRequestError) do
+ error_fetch.content_type
+ end
+ end
+
+ test "content" do
+ fetch = Link::Fetch.new("https://example.com/page")
+
+ stub_request(:get, "https://example.com/page")
+ .to_return(status: 200, body: "