tidewave 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e88c68fa913f5012d8fa2b69353e4d2d290a36e4a2ebbc4194ccb220990ce75
4
- data.tar.gz: f132fbe9a0c4d15f4f2f91773af9cbaebadfacbb98aac2d8a6ceeeccdb1903eb
3
+ metadata.gz: d708afbfcf6d89cc1456bd7bcefdc660952571cda0c6635b8c6b14b8fa31d91e
4
+ data.tar.gz: da923b2f69608fe95eb11f76795cef1598f9cc24941afe50081afc4b76c66cfa
5
5
  SHA512:
6
- metadata.gz: 37c5d8dfd49cb1fcb2d37302f0ea8ddf715262c6054405935fe3568741c50b65e26a2a6731dd64edb68791a2fd8bb58f20332a8119362c8949b18db8a1c82a07
7
- data.tar.gz: 7bdd172eb19ef223ab972686d310f35a690331a5678467b22fb2d0bf70c401173784a47be2aa035e5bc704a602cb8e28ee7aaed1f6c18aff62ea743dd628a652
6
+ metadata.gz: 9b0eaa2a77690e4dda1720e5c2c84b02ea93d9b69955e2e430745da483d30014ba3fc5d59dc40c92b0ccdedd4413f4198012813a33f2332fd7ca08a9fac79fc1
7
+ data.tar.gz: 501103bf39a69932a408ebf9e455640fc8fb97a507163d6b4f42faf27aead8811927ecc249858978c8c5a46213dbe5b1d8635ade60817f546645ec99fa4c203a
data/README.md CHANGED
@@ -6,17 +6,6 @@ assistant to your web framework runtime via [MCP](https://modelcontextprotocol.i
6
6
 
7
7
  [See our website](https://tidewave.ai) for more information.
8
8
 
9
- ## Key Features
10
-
11
- Tidewave provides tools that allow your LLM of choice to:
12
-
13
- - inspect your application logs to help debugging errors
14
- - execute SQL queries and inspect your database
15
- - evaluate custom Ruby code in the context of your project
16
- - find Rubygems packages and source code locations
17
-
18
- and more.
19
-
20
9
  ## Installation
21
10
 
22
11
  You can install Tidewave by adding the `tidewave` gem to the development group in your Gemfile:
@@ -29,37 +18,47 @@ Tidewave will now run on the same port as your regular Rails application.
29
18
  In particular, the MCP is located by default at http://localhost:3000/tidewave/mcp.
30
19
  [You must configure your editor and AI assistants accordingly](https://hexdocs.pm/tidewave/mcp.html).
31
20
 
32
- ## Configuration
21
+ ## Troubleshooting
33
22
 
34
- You may configure the `tidewave` using the following syntax:
23
+ ### Localhost requirement
24
+
25
+ Tidewave expects your web application to be running on `localhost`. If you are not running on localhost, you may need to set some additional configuration. In particular, you must configure Tidewave to allow `allow_remote_access` and [optionally configure your Rails hosts](https://guides.rubyonrails.org/configuring.html#actiondispatch-hostauthorization). For example, in your `config/environments/development.rb`:
35
26
 
36
27
  ```ruby
37
- config.tidewave.allowed_ips = [IPAddr.new("192.168.97.1")]
28
+ config.hosts << "company.local"
29
+ config.tidewave.allow_remote_access = true
38
30
  ```
39
31
 
40
- The following options are available:
41
-
42
- * `:allowed_origins`
43
-
44
- * `:localhost_only`
45
-
46
- * `:allowed_ips`
32
+ If you want to use Docker for development, you either need to enable the configuration above or automatically redirect the relevant ports, as done by [devcontainers](https://code.visualstudio.com/docs/devcontainers/containers). See our [containars](https://hexdocs.pm/tidewave/containers.html) guide for more information.
47
33
 
48
- You can read more about this options in [FastMCP](https://github.com/yjacquin/fast_mcp) README.
34
+ ### Content security policy
49
35
 
50
- ## Considerations
36
+ If you have enabled Content-Security-Policy, Tidewave will automatically enable "unsafe-eval" under `script-src` in order for contextual browser testing to work correctly.
51
37
 
52
38
  ### Production Environment
53
39
 
54
- Tidewave is a powerful tool that can help you develop your web application faster and more efficiently.
55
- However, it is important to note that Tidewave is not meant to be used in a production environment.
40
+ Tidewave is a powerful tool that can help you develop your web application faster and more efficiently. However, it is important to note that Tidewave is not meant to be used in a production environment.
56
41
 
57
- Tidewave will raise an error if it is used in a production environment.
42
+ Tidewave will raise an error if it is used in any environment where code reloading is disabled (which typically includes production).
58
43
 
59
44
  ### Web server requirements
60
45
 
61
46
  Tidewave currently requires a threaded web server like Puma.
62
47
 
48
+ ## Configuration
49
+
50
+ You may configure `tidewave` using the following syntax:
51
+
52
+ ```ruby
53
+ config.tidewave.allow_remote_access = true
54
+ ```
55
+
56
+ The following options are available:
57
+
58
+ * `:allow_remote_access` - Tidewave only allows requests from localhost by default, even if your server listens on other interfaces as well. If you trust your network and need to access Tidewave from a different machine, this configuration can be set to `true`
59
+
60
+ * `:preferred_orm` - which ORM to use, either `:active_record` (default) or `:sequel`
61
+
63
62
  ## Acknowledgements
64
63
 
65
64
  A thank you to Yorick Jacquin, for creating [FastMCP](https://github.com/yjacquin/fast_mcp) and implementing the initial version of this project.
@@ -2,13 +2,14 @@
2
2
 
3
3
  module Tidewave
4
4
  class Configuration
5
- attr_accessor :logger, :allowed_origins, :localhost_only, :allowed_ips
5
+ attr_accessor :logger, :allow_remote_access, :preferred_orm, :credentials, :client_url
6
6
 
7
7
  def initialize
8
- @logger = Logger.new(STDOUT)
9
- @allowed_origins = nil
10
- @localhost_only = true
11
- @allowed_ips = nil
8
+ @logger = nil
9
+ @allow_remote_access = true
10
+ @preferred_orm = :active_record
11
+ @credentials = {}
12
+ @client_url = "https://tidewave.ai"
12
13
  end
13
14
  end
14
15
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tidewave
4
+ class DatabaseAdapter
5
+ class << self
6
+ def current
7
+ @current ||= create_adapter
8
+ end
9
+
10
+ def create_adapter
11
+ orm_type = Rails.application.config.tidewave.preferred_orm
12
+ case orm_type
13
+ when :active_record
14
+ require_relative "database_adapters/active_record"
15
+ DatabaseAdapters::ActiveRecord.new
16
+ when :sequel
17
+ require_relative "database_adapters/sequel"
18
+ DatabaseAdapters::Sequel.new
19
+ else
20
+ raise "Unknown preferred ORM: #{orm_type}"
21
+ end
22
+ end
23
+ end
24
+
25
+ def execute_query(query, arguments = [])
26
+ raise NotImplementedError, "Subclasses must implement execute_query"
27
+ end
28
+
29
+ def get_base_class
30
+ raise NotImplementedError, "Subclasses must implement get_base_class"
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tidewave
4
+ module DatabaseAdapters
5
+ class ActiveRecord < DatabaseAdapter
6
+ RESULT_LIMIT = 50
7
+
8
+ def execute_query(query, arguments = [])
9
+ conn = ::ActiveRecord::Base.connection
10
+
11
+ # Execute the query with prepared statement and arguments
12
+ if arguments.any?
13
+ result = conn.exec_query(query, "SQL", arguments)
14
+ else
15
+ result = conn.exec_query(query)
16
+ end
17
+
18
+ # Format the result
19
+ {
20
+ columns: result.columns,
21
+ rows: result.rows.first(RESULT_LIMIT),
22
+ row_count: result.rows.length,
23
+ adapter: conn.adapter_name,
24
+ database: database_name
25
+ }
26
+ end
27
+
28
+ def get_base_class
29
+ ::ActiveRecord::Base
30
+ end
31
+
32
+ private
33
+
34
+ def database_name
35
+ Rails.configuration.database_configuration.dig(Rails.env, "database")
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tidewave
4
+ module DatabaseAdapters
5
+ class Sequel < DatabaseAdapter
6
+ RESULT_LIMIT = 50
7
+
8
+ def execute_query(query, arguments = [])
9
+ db = ::Sequel::Model.db
10
+
11
+ # Execute the query with arguments
12
+ result = if arguments.any?
13
+ db.fetch(query, *arguments)
14
+ else
15
+ db.fetch(query)
16
+ end
17
+
18
+ # Convert to array of hashes and extract metadata
19
+ rows = result.all
20
+ columns = rows.first&.keys || []
21
+
22
+ # Format the result similar to ActiveRecord
23
+ {
24
+ columns: columns.map(&:to_s),
25
+ rows: rows.first(RESULT_LIMIT).map(&:values),
26
+ row_count: rows.length,
27
+ adapter: db.adapter_scheme.to_s.upcase,
28
+ database: db.opts[:database]
29
+ }
30
+ end
31
+
32
+ def get_base_class
33
+ ::Sequel::Model
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Tidewave::ExceptionsMiddleware
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def call(env)
9
+ request = ActionDispatch::Request.new(env)
10
+ status, headers, body = @app.call(env)
11
+
12
+ exception = request.get_header("tidewave.exception")
13
+
14
+ if exception
15
+ formatted = format_exception_html(exception, request)
16
+ body, headers = append_body(body, headers, formatted)
17
+ end
18
+
19
+ [ status, headers, body ]
20
+ rescue => error
21
+ Rails.logger.error("Failure in Tidewave::ExceptionsMiddleware, message: #{error.message}")
22
+ raise error
23
+ end
24
+
25
+ private
26
+
27
+ def format_exception_html(exception, request)
28
+ backtrace = Rails.backtrace_cleaner.clean(exception.backtrace)
29
+
30
+ parameters = request_parameters(request)
31
+
32
+ text = exception.class.name.dup
33
+
34
+ if parameters["controller"]
35
+ text << " in #{parameters["controller"].camelize}Controller"
36
+
37
+ if parameters["action"]
38
+ text << "##{parameters["action"]}"
39
+ end
40
+ end
41
+
42
+ text << "\n\n## Message\n\n#{exception.message}"
43
+
44
+ if backtrace.any?
45
+ text << "\n\n## Backtrace\n\n#{backtrace.join("\n")}"
46
+ end
47
+
48
+ text << "\n\n"
49
+
50
+ text << <<~TEXT.chomp
51
+ ## Request info
52
+
53
+ * URI: #{request.base_url + request.path}
54
+ * Query string: #{request.query_string}
55
+
56
+ ## Session
57
+
58
+ #{request.session.to_hash.inspect}
59
+ TEXT
60
+
61
+ %Q(<textarea style="display: none;" data-tidewave-exception-info>#{ERB::Util.html_escape(text)}</textarea>)
62
+ end
63
+
64
+ def request_parameters(request)
65
+ request.parameters
66
+ rescue ActionController::BadRequest
67
+ {}
68
+ end
69
+
70
+ def append_body(body, headers, content)
71
+ new_body = "".dup
72
+
73
+ body.each { |part| new_body << part }
74
+ body.close if body.respond_to?(:close)
75
+
76
+ if position = new_body.rindex("</body>")
77
+ new_body.insert(position, content)
78
+ else
79
+ new_body << content
80
+ end
81
+
82
+ if headers[Rack::CONTENT_LENGTH]
83
+ headers[Rack::CONTENT_LENGTH] = new_body.bytesize.to_s
84
+ end
85
+
86
+ [ [ new_body ], headers ]
87
+ end
88
+ end
@@ -4,120 +4,99 @@ module Tidewave
4
4
  module FileTracker
5
5
  extend self
6
6
 
7
- def project_files
8
- `git --git-dir #{git_root}/.git ls-files --cached --others --exclude-standard`.split("\n")
7
+ def project_files(glob_pattern: nil, include_ignored: false)
8
+ args = [ "ls-files", "--cached", "--others" ]
9
+ args << "--exclude-standard" unless include_ignored
10
+ args << glob_pattern if glob_pattern
11
+ `git #{args.join(" ")}`.split("\n")
9
12
  end
10
13
 
11
- def read_file(path)
12
- validate_path_access!(path)
13
-
14
- # Retrieve the full path
14
+ def read_file(path, line_offset: 0, count: nil)
15
15
  full_path = file_full_path(path)
16
+ # Explicitly read the mtime first to avoid race conditions
17
+ mtime = File.mtime(full_path).to_i
18
+ content = File.read(full_path)
16
19
 
17
- # Record the file as read
18
- record_read(path)
20
+ if line_offset > 0 || count
21
+ lines = content.lines
22
+ start_idx = [ line_offset, 0 ].max
23
+ count = (count || lines.length)
24
+ selected_lines = lines[start_idx, count]
25
+ content = selected_lines ? selected_lines.join : ""
26
+ end
19
27
 
20
- # Read and return the file contents
21
- File.read(full_path)
28
+ [ mtime, content ]
22
29
  end
23
30
 
24
31
  def write_file(path, content)
25
- validate_path_access!(path, validate_existence: false)
26
- # Retrieve the full path
32
+ validate_ruby_syntax!(content) if ruby_file?(path)
27
33
  full_path = file_full_path(path)
28
34
 
29
- dirname = File.dirname(full_path)
30
-
31
35
  # Create the directory if it doesn't exist
36
+ dirname = File.dirname(full_path)
32
37
  FileUtils.mkdir_p(dirname)
33
38
 
34
- # Write the file contents
39
+ # Write and return the file contents
35
40
  File.write(full_path, content)
36
-
37
- # Read and return the file contents
38
- read_file(path)
41
+ content
39
42
  end
40
43
 
41
44
  def file_full_path(path)
42
- File.join(git_root, path)
43
- end
44
-
45
- def git_root
46
- @git_root ||= `git rev-parse --show-toplevel`.strip
45
+ File.expand_path(path, Rails.root)
47
46
  end
48
47
 
49
48
  def validate_path_access!(path, validate_existence: true)
50
- raise ArgumentError, "File path must not start with '..'" if path.start_with?("..")
49
+ raise ArgumentError, "File path must not contain '..'" if path.include?("..")
51
50
 
52
51
  # Ensure the path is within the project
53
52
  full_path = file_full_path(path)
54
53
 
55
54
  # Verify the file is within the project directory
56
- raise ArgumentError, "File path must be within the project directory" unless full_path.start_with?(git_root)
55
+ unless full_path.start_with?(Rails.root.to_s + File::SEPARATOR)
56
+ raise ArgumentError, "File path must be within the project directory"
57
+ end
57
58
 
58
59
  # Verify the file exists
59
- raise ArgumentError, "File not found: #{path}" unless File.exist?(full_path) && validate_existence
60
+ if validate_existence && !File.exist?(full_path)
61
+ raise ArgumentError, "File not found: #{path}"
62
+ end
60
63
 
61
64
  true
62
65
  end
63
66
 
64
- def validate_path_is_editable!(path)
67
+ def validate_path_is_editable!(path, atime)
65
68
  validate_path_access!(path)
66
- validate_path_has_been_read_since_last_write!(path)
69
+ validate_path_has_been_read_since_last_write!(path, atime)
67
70
 
68
71
  true
69
72
  end
70
73
 
71
- def validate_path_is_writable!(path)
74
+ def validate_path_is_writable!(path, atime)
72
75
  validate_path_access!(path, validate_existence: false)
73
- validate_path_has_been_read_since_last_write!(path)
76
+ validate_path_has_been_read_since_last_write!(path, atime)
74
77
 
75
78
  true
76
79
  end
77
80
 
78
- def validate_path_has_been_read_since_last_write!(path)
79
- raise ArgumentError, "File has been modified since last read, please read the file again" unless file_was_read_since_last_write?(path)
81
+ private
80
82
 
81
- true
83
+ def ruby_file?(path)
84
+ [ ".rb", ".rake", ".gemspec" ].include?(File.extname(path)) ||
85
+ [ "Gemfile" ].include?(File.basename(path))
82
86
  end
83
87
 
84
- # Record when a file was read
85
- def record_read(path)
86
- file_records[path] = Time.now
88
+ def validate_ruby_syntax!(content)
89
+ RubyVM::AbstractSyntaxTree.parse(content)
90
+ rescue SyntaxError => e
91
+ raise "Invalid Ruby syntax: #{e.message}"
87
92
  end
88
93
 
94
+ def validate_path_has_been_read_since_last_write!(path, atime)
95
+ if atime && File.mtime(file_full_path(path)).to_i > atime
96
+ raise ArgumentError, "File has been modified since last read, please read the file again"
97
+ end
89
98
 
90
- def file_was_read_since_last_write?(path)
91
- file_was_read?(path) && last_read_at(path) >= last_modified_at(path)
92
- end
93
-
94
- # Check if a file has been read
95
- def file_was_read?(path)
96
- file_records.key?(path)
97
- end
98
-
99
- # Check if a file exists
100
- def file_exists?(path)
101
- File.exist?(file_full_path(path))
102
- end
103
-
104
- # Get the timestamp when a file was last read
105
- def last_read_at(path)
106
- file_records[path]
107
- end
108
-
109
- def last_modified_at(path)
110
- File.mtime(file_full_path(path))
111
- end
112
-
113
- # Reset all tracked files (useful for testing)
114
- def reset
115
- @file_records = {}
116
- end
117
-
118
- # Hash mapping file paths to their read records
119
- def file_records
120
- @file_records ||= {}
99
+ true
121
100
  end
122
101
  end
123
102
  end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "ipaddr"
5
+ require "fast_mcp"
6
+ require "rack/request"
7
+ require "active_support/core_ext/class"
8
+ require "active_support/core_ext/object/blank"
9
+ require "json"
10
+ require "erb"
11
+
12
+ class Tidewave::Middleware
13
+ TIDEWAVE_ROUTE = "tidewave".freeze
14
+ EMPTY_ROUTE = "empty".freeze
15
+ SSE_ROUTE = "mcp".freeze
16
+ MESSAGES_ROUTE = "mcp/message".freeze
17
+ SHELL_ROUTE = "shell".freeze
18
+
19
+ INVALID_IP = <<~TEXT.freeze
20
+ For security reasons, Tidewave does not accept remote connections by default.
21
+
22
+ If you really want to allow remote connections, set `config.tidewave.allow_remote_access = true`.
23
+ TEXT
24
+
25
+ def initialize(app, config)
26
+ @allow_remote_access = config.allow_remote_access
27
+ @client_url = config.client_url
28
+ @project_name = Rails.application.class.module_parent.name
29
+
30
+ @app = FastMcp.rack_middleware(app,
31
+ name: "tidewave",
32
+ version: Tidewave::VERSION,
33
+ path_prefix: "/" + TIDEWAVE_ROUTE,
34
+ messages_route: MESSAGES_ROUTE,
35
+ sse_route: SSE_ROUTE,
36
+ logger: config.logger || Logger.new(Rails.root.join("log", "tidewave.log")),
37
+ # Rails runs the HostAuthorization in dev, so we skip this
38
+ allowed_origins: [],
39
+ # We validate this one in Tidewave::Middleware
40
+ localhost_only: false
41
+ ) do |server|
42
+ server.filter_tools do |request, tools|
43
+ if request.params["include_fs_tools"] != "true"
44
+ tools.reject { |tool| tool.tags.include?(:file_system_tool) }
45
+ else
46
+ tools
47
+ end
48
+ end
49
+
50
+ server.register_tools(*Tidewave::Tools::Base.descendants)
51
+ end
52
+ end
53
+
54
+ def call(env)
55
+ request = Rack::Request.new(env)
56
+ path = request.path.split("/").reject(&:empty?)
57
+
58
+ if path[0] == TIDEWAVE_ROUTE
59
+ return forbidden(INVALID_IP) unless valid_client_ip?(request)
60
+
61
+ # The MCP routes are handled downstream by FastMCP
62
+ case [ request.request_method, path ]
63
+ when [ "GET", [ TIDEWAVE_ROUTE ] ]
64
+ return home(request)
65
+ when [ "GET", [ TIDEWAVE_ROUTE, EMPTY_ROUTE ] ]
66
+ return empty(request)
67
+ when [ "POST", [ TIDEWAVE_ROUTE, SHELL_ROUTE ] ]
68
+ return shell(request)
69
+ end
70
+ end
71
+
72
+ @app.call(env)
73
+ end
74
+
75
+ private
76
+
77
+ def home(request)
78
+ config = {
79
+ "project_name" => @project_name,
80
+ "framework_type" => "rails",
81
+ "tidewave_version" => Tidewave::VERSION
82
+ }
83
+
84
+ html = <<~HTML
85
+ <html>
86
+ <head>
87
+ <meta charset="UTF-8" />
88
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
89
+ <meta name="tidewave:config" content="#{ERB::Util.html_escape(JSON.generate(config))}" />
90
+ <script type="module" src="#{@client_url}/tc/tc.js"></script>
91
+ </head>
92
+ <body></body>
93
+ </html>
94
+ HTML
95
+
96
+ [ 200, { "Content-Type" => "text/html" }, [ html ] ]
97
+ end
98
+
99
+ def empty(request)
100
+ html = ""
101
+ [ 200, { "Content-Type" => "text/html" }, [ html ] ]
102
+ end
103
+
104
+ def forbidden(message)
105
+ Rails.logger.warn(message)
106
+ [ 403, { "Content-Type" => "text/plain" }, [ message ] ]
107
+ end
108
+
109
+ def shell(request)
110
+ body = request.body.read
111
+ return [ 400, { "Content-Type" => "text/plain" }, [ "Command body is required" ] ] if body.blank?
112
+
113
+ begin
114
+ parsed_body = JSON.parse(body)
115
+ cmd = parsed_body["command"]
116
+ return [ 400, { "Content-Type" => "text/plain" }, [ "Command field is required" ] ] if cmd.blank?
117
+ rescue JSON::ParserError
118
+ return [ 400, { "Content-Type" => "text/plain" }, [ "Invalid JSON in request body" ] ]
119
+ end
120
+
121
+ response = Rack::Response.new
122
+ response.status = 200
123
+ response.headers["Content-Type"] = "text/plain"
124
+
125
+ response.finish do |res|
126
+ begin
127
+ Open3.popen3(*cmd) do |stdin, stdout, stderr, wait_thr|
128
+ stdin.close
129
+
130
+ # Merge stdout and stderr streams
131
+ ios = [ stdout, stderr ]
132
+
133
+ until ios.empty?
134
+ ready = IO.select(ios, nil, nil, 0.1)
135
+ next unless ready
136
+
137
+ ready[0].each do |io|
138
+ begin
139
+ data = io.read_nonblock(4096)
140
+ if data
141
+ # Write binary chunk: type (0 for data) + 4-byte length + data
142
+ chunk = [ 0, data.bytesize ].pack("CN") + data
143
+ res.write(chunk)
144
+ end
145
+ rescue IO::WaitReadable
146
+ # No data available right now
147
+ rescue EOFError
148
+ # Stream ended
149
+ ios.delete(io)
150
+ end
151
+ end
152
+ end
153
+
154
+ # Wait for process to complete and get exit status
155
+ exit_status = wait_thr.value.exitstatus
156
+ status_json = JSON.generate({ status: exit_status })
157
+ # Write binary chunk: type (1 for status) + 4-byte length + JSON data
158
+ chunk = [ 1, status_json.bytesize ].pack("CN") + status_json
159
+ res.write(chunk)
160
+ end
161
+ rescue => e
162
+ error_json = JSON.generate({ status: 213 })
163
+ chunk = [ 1, error_json.bytesize ].pack("CN") + error_json
164
+ res.write(chunk)
165
+ end
166
+ end
167
+ end
168
+
169
+ def valid_client_ip?(request)
170
+ return true if @allow_remote_access
171
+
172
+ ip = request.ip
173
+ return false unless ip
174
+
175
+ addr = IPAddr.new(ip)
176
+
177
+ addr.loopback? ||
178
+ addr == IPAddr.new("127.0.0.1") ||
179
+ addr == IPAddr.new("::1") ||
180
+ addr == IPAddr.new("::ffff:127.0.0.1") # IPv4-mapped IPv6
181
+ end
182
+ end