tidewave 0.5.2 → 0.8.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 +4 -4
- data/README.md +8 -4
- data/lib/tidewave/configuration.rb +2 -1
- data/lib/tidewave/magic_bytes.rb +20 -0
- data/lib/tidewave/railtie.rb +14 -2
- data/lib/tidewave/tools/execute_sql_query.rb +12 -1
- data/lib/tidewave/version.rb +1 -1
- data/lib/tidewave.rb +236 -7
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d8aec0fffb76b0c91651452a2d8e2b1adc3fe37681a0c778c01a4762602985ed
|
|
4
|
+
data.tar.gz: d0f6abae17bcc8281baed3f3db784b0243186c5008a8866a612e42bda4333e14
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8b683cb4a188e66e456078a39467a10ff6bc9aa1ac3b3f2e92338eadf5cbe1b0a693e6d80dd32d6e8dfdb167dd0b83af1d1183c442fc6d8348beb036180e05ba
|
|
7
|
+
data.tar.gz: b3366af6bdd77c9312e8e6d70e56d898b02f75555f9b1932fea379bdb36621a8381f31df8696f86f7a6a5604885ae0ded2f22ee51a9bf323e80c7c7e52422c99
|
data/README.md
CHANGED
|
@@ -12,9 +12,7 @@ Your agent will be able to use this MCP server to talk to your running Rails app
|
|
|
12
12
|
|
|
13
13
|
This MCP server is an open-source component of [Tidewave](https://tidewave.ai), the agentic development environment for Rails and Phoenix.
|
|
14
14
|
|
|
15
|
-
You can use this project as a standalone MCP server or integrated with the [Tidewave product](https://tidewave.ai).
|
|
16
|
-
|
|
17
|
-
To use it as a standalone MCP server, follow the installation instructions below.
|
|
15
|
+
You can use this project as a standalone MCP server or integrated with the [Tidewave product](https://tidewave.ai) by following the instructions below.
|
|
18
16
|
|
|
19
17
|
## Installation
|
|
20
18
|
|
|
@@ -117,6 +115,10 @@ Also, because it resolves the location from your running app instead of parsing
|
|
|
117
115
|
|
|
118
116
|
## Troubleshooting
|
|
119
117
|
|
|
118
|
+
### The Tidewave toolbar is missing
|
|
119
|
+
|
|
120
|
+
This may happen if you are compressing your responses (gzip, brotli, etc) after the Tidewave middleware runs. Use `bin/rails middleware` and make sure Tidewave comes after `Rack::Deflater` or similar. Also look into your browser and terminal logs for any errors.
|
|
121
|
+
|
|
120
122
|
### Using multiple hosts/subdomains
|
|
121
123
|
|
|
122
124
|
If you are using multiple hosts/subdomains during development, you must use `*.localhost`, as such domains are considered secure by browsers. Additionally, add the following to `config/initializers/development.rb`:
|
|
@@ -135,7 +137,7 @@ The above will allow your application to run embedded within Tidewave across mul
|
|
|
135
137
|
|
|
136
138
|
### Content security policy
|
|
137
139
|
|
|
138
|
-
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. It also disables the `frame-ancestors` directive.
|
|
140
|
+
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. It also disables the `frame-ancestors` directive. This is done only in the environments that Tidewave is loadead (development by default).
|
|
139
141
|
|
|
140
142
|
### Production Environment
|
|
141
143
|
|
|
@@ -161,6 +163,8 @@ The following config is available:
|
|
|
161
163
|
|
|
162
164
|
* `team` - set your Tidewave Team configuration, such as `config.tidewave.team = { id: "my-company" }`
|
|
163
165
|
|
|
166
|
+
* `toolbar` - controls whether the Tidewave toolbar is injected into HTML pages. Defaults to `true`
|
|
167
|
+
|
|
164
168
|
## Acknowledgements
|
|
165
169
|
|
|
166
170
|
A thank you to Yorick Jacquin for the initial version of this project.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
class Tidewave
|
|
4
4
|
class Configuration
|
|
5
|
-
attr_accessor :logger, :allow_remote_access, :preferred_orm, :dev, :client_url, :team, :logger_middleware
|
|
5
|
+
attr_accessor :logger, :allow_remote_access, :preferred_orm, :dev, :client_url, :team, :logger_middleware, :toolbar
|
|
6
6
|
|
|
7
7
|
def initialize
|
|
8
8
|
# Rails has a hosts middleware which already checks for this
|
|
@@ -13,6 +13,7 @@ class Tidewave
|
|
|
13
13
|
@client_url = "https://tidewave.ai"
|
|
14
14
|
@team = {}
|
|
15
15
|
@logger_middleware = nil
|
|
16
|
+
@toolbar = true
|
|
16
17
|
end
|
|
17
18
|
end
|
|
18
19
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Tidewave
|
|
4
|
+
module MagicBytes
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def type(bytes)
|
|
8
|
+
case bytes
|
|
9
|
+
when /\A\xFF\xD8\xFF/n
|
|
10
|
+
:jpg
|
|
11
|
+
when /\A\x89PNG\r\n\x1A\n/n
|
|
12
|
+
:png
|
|
13
|
+
when /\A\x1A\x45\xDF\xA3/n
|
|
14
|
+
bytes.include?("webm") ? :webm : :unknown
|
|
15
|
+
else
|
|
16
|
+
:unknown
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/tidewave/railtie.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "logger"
|
|
4
|
+
require "uri"
|
|
4
5
|
require "tidewave/configuration"
|
|
5
6
|
require "tidewave/exceptions_middleware"
|
|
6
7
|
require "tidewave/quiet_requests_middleware"
|
|
@@ -24,6 +25,7 @@ class Tidewave
|
|
|
24
25
|
framework_type: "rails",
|
|
25
26
|
project_name: app.class.module_parent.name,
|
|
26
27
|
team: tidewave_config.team,
|
|
28
|
+
toolbar: tidewave_config.toolbar,
|
|
27
29
|
logger: tidewave_config.logger || Rails.logger,
|
|
28
30
|
root: Rails.root,
|
|
29
31
|
log_file: Rails.root.join("log", "#{Rails.env}.log"),
|
|
@@ -35,11 +37,21 @@ class Tidewave
|
|
|
35
37
|
# If the user configured CSP, we need to alter it in dev
|
|
36
38
|
# to allow TC to run browser_eval.
|
|
37
39
|
app.config.content_security_policy.try do |content_security_policy|
|
|
38
|
-
content_security_policy.directives
|
|
40
|
+
directives = content_security_policy.directives
|
|
41
|
+
script_src = directives["script-src"] || directives["default-src"]&.dup
|
|
42
|
+
client_origin = URI.parse(tidewave_config.client_url.to_s).origin
|
|
43
|
+
|
|
44
|
+
script_src.try do
|
|
39
45
|
script_src << "'unsafe-eval'" unless script_src.include?("'unsafe-eval'")
|
|
46
|
+
script_src << client_origin unless script_src.include?(client_origin)
|
|
47
|
+
directives["script-src"] = script_src
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
directives["script-src-elem"].try do |script_src_elem|
|
|
51
|
+
script_src_elem << client_origin unless script_src_elem.include?(client_origin)
|
|
40
52
|
end
|
|
41
53
|
|
|
42
|
-
|
|
54
|
+
directives.delete("frame-ancestors")
|
|
43
55
|
end
|
|
44
56
|
end
|
|
45
57
|
end
|
|
@@ -48,6 +48,17 @@ class Tidewave::Tools::ExecuteSqlQuery < Tidewave::Tool
|
|
|
48
48
|
def call(arguments_hash)
|
|
49
49
|
query = arguments_hash.fetch("query")
|
|
50
50
|
arguments = arguments_hash.fetch("arguments", [])
|
|
51
|
-
@database_adapter.execute_query(query, arguments)
|
|
51
|
+
result = @database_adapter.execute_query(query, arguments)
|
|
52
|
+
|
|
53
|
+
preamble = if result[:row_count] > result[:rows].length
|
|
54
|
+
<<~TEXT
|
|
55
|
+
Query returned #{result[:row_count]} rows. Only the first #{result[:rows].length} rows are included in the result. Use your database's pagination syntax, such as LIMIT + OFFSET, to show more rows if applicable.
|
|
56
|
+
|
|
57
|
+
TEXT
|
|
58
|
+
else
|
|
59
|
+
""
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
preamble + result.inspect
|
|
52
63
|
end
|
|
53
64
|
end
|
data/lib/tidewave/version.rb
CHANGED
data/lib/tidewave.rb
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "cgi"
|
|
3
5
|
require "ipaddr"
|
|
4
6
|
require "json"
|
|
7
|
+
require "pathname"
|
|
5
8
|
require "rack/request"
|
|
9
|
+
require "uri"
|
|
6
10
|
require "tidewave/version"
|
|
7
11
|
require "tidewave/tool"
|
|
8
12
|
require "tidewave/database_adapter"
|
|
13
|
+
require "tidewave/magic_bytes"
|
|
9
14
|
require "tidewave/railtie" if defined?(Rails::Railtie)
|
|
10
15
|
|
|
11
16
|
class Tidewave
|
|
@@ -25,10 +30,56 @@ Dir[gem_tools_path].sort.each do |file|
|
|
|
25
30
|
end
|
|
26
31
|
|
|
27
32
|
class Tidewave
|
|
33
|
+
class ToolbarBody
|
|
34
|
+
def initialize(body, toolbar)
|
|
35
|
+
@body = body
|
|
36
|
+
@toolbar = toolbar
|
|
37
|
+
@closed = false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def each
|
|
41
|
+
return enum_for(:each) unless block_given?
|
|
42
|
+
|
|
43
|
+
pending = +""
|
|
44
|
+
injected = false
|
|
45
|
+
|
|
46
|
+
@body.each do |part|
|
|
47
|
+
if injected
|
|
48
|
+
yield part
|
|
49
|
+
else
|
|
50
|
+
pending << part
|
|
51
|
+
|
|
52
|
+
if closing_head = pending.downcase.index("</head>")
|
|
53
|
+
toolbar = @toolbar.dup.force_encoding(pending.encoding)
|
|
54
|
+
output = pending.insert(closing_head, toolbar)
|
|
55
|
+
pending = nil
|
|
56
|
+
injected = true
|
|
57
|
+
yield output
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
yield pending unless injected || pending.empty?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def close
|
|
66
|
+
return if @closed
|
|
67
|
+
|
|
68
|
+
@closed = true
|
|
69
|
+
@body.close if @body.respond_to?(:close)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
28
73
|
TIDEWAVE_ROUTE = "tidewave".freeze
|
|
29
74
|
MCP_ROUTE = "mcp".freeze
|
|
30
75
|
CONFIG_ROUTE = "config".freeze
|
|
76
|
+
APP_ROUTE = "app".freeze
|
|
77
|
+
UPLOAD_ROUTE = "upload".freeze
|
|
31
78
|
PROTOCOL_VERSION = "2025-03-26".freeze
|
|
79
|
+
MAX_UPLOAD_SIZE = 10_000_000
|
|
80
|
+
ALLOWED_UPLOAD_CONTENT_TYPES = [ "image/png", "image/jpeg", "video/webm" ].freeze
|
|
81
|
+
ALLOWED_UPLOAD_TYPES = [ "screenshot", "recording" ].freeze
|
|
82
|
+
TMP_DIR = "tmp".freeze
|
|
32
83
|
|
|
33
84
|
INVALID_IP = <<~TEXT.freeze
|
|
34
85
|
For security reasons, Tidewave does not accept remote connections by default.
|
|
@@ -37,12 +88,19 @@ class Tidewave
|
|
|
37
88
|
TEXT
|
|
38
89
|
|
|
39
90
|
INVALID_ORIGIN = "For security reasons, Tidewave does not accept requests with an origin header for this endpoint.".freeze
|
|
91
|
+
INVALID_UPLOAD = "Bad Request: missing or invalid file parameter".freeze
|
|
92
|
+
ENCODED_HTML_WARNING = <<~TEXT.freeze
|
|
93
|
+
Tidewave could not inject the toolbar because the HTML response is encoded.
|
|
94
|
+
|
|
95
|
+
If you use Rack::Deflater or another compression middleware, place it before Tidewave in the middleware stack.
|
|
96
|
+
TEXT
|
|
40
97
|
|
|
41
98
|
DEFAULT_OPTIONS = {
|
|
42
99
|
allow_remote_access: false,
|
|
43
100
|
client_url: "https://tidewave.ai",
|
|
44
101
|
framework_type: "rack",
|
|
45
|
-
team: {}
|
|
102
|
+
team: {},
|
|
103
|
+
toolbar: true
|
|
46
104
|
}.freeze
|
|
47
105
|
|
|
48
106
|
def initialize(app, options = {})
|
|
@@ -51,6 +109,7 @@ class Tidewave
|
|
|
51
109
|
raise ArgumentError, "project_name is required" if @options[:project_name].to_s.empty?
|
|
52
110
|
|
|
53
111
|
@logger = @options[:logger]
|
|
112
|
+
@root = @options[:root] ? Pathname.new(@options[:root].to_s) : Pathname.pwd
|
|
54
113
|
@tools = build_tool_registry
|
|
55
114
|
end
|
|
56
115
|
|
|
@@ -60,24 +119,27 @@ class Tidewave
|
|
|
60
119
|
|
|
61
120
|
if path[0] == TIDEWAVE_ROUTE
|
|
62
121
|
return forbidden(INVALID_IP) unless valid_client_ip?(request)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
end
|
|
122
|
+
|
|
123
|
+
return forbidden(INVALID_ORIGIN) if request.get_header("HTTP_ORIGIN") && !origin_allowed_path?(path)
|
|
66
124
|
|
|
67
125
|
case [ request.request_method, path ]
|
|
68
126
|
when [ "GET", [ TIDEWAVE_ROUTE ] ]
|
|
69
127
|
home_endpoint(request)
|
|
128
|
+
when [ "GET", [ TIDEWAVE_ROUTE, APP_ROUTE ] ]
|
|
129
|
+
app_endpoint(request)
|
|
70
130
|
when [ "GET", [ TIDEWAVE_ROUTE, CONFIG_ROUTE ] ]
|
|
71
131
|
config_endpoint(request)
|
|
72
132
|
when [ "POST", [ TIDEWAVE_ROUTE, MCP_ROUTE ] ]
|
|
73
133
|
mcp_endpoint(request)
|
|
134
|
+
when [ "POST", [ TIDEWAVE_ROUTE, UPLOAD_ROUTE ] ]
|
|
135
|
+
upload_endpoint(request)
|
|
74
136
|
else
|
|
75
137
|
# The MCP Streamable HTTP transport requires the MCP endpoint to answer
|
|
76
138
|
# non-POST methods with 405 (GET without SSE support, DELETE, etc.)
|
|
77
139
|
path == [ TIDEWAVE_ROUTE, MCP_ROUTE ] ? method_not_allowed() : not_found()
|
|
78
140
|
end
|
|
79
141
|
else
|
|
80
|
-
strip_x_frame_options(@app.call(env))
|
|
142
|
+
inject_toolbar(request, strip_x_frame_options(@app.call(env)))
|
|
81
143
|
end
|
|
82
144
|
end
|
|
83
145
|
|
|
@@ -106,6 +168,25 @@ class Tidewave
|
|
|
106
168
|
[ 200, response_headers("text/html", body), [ body ] ]
|
|
107
169
|
end
|
|
108
170
|
|
|
171
|
+
def app_endpoint(_request)
|
|
172
|
+
client_url = @options[:client_url].to_s.sub(%r{/\z}, "")
|
|
173
|
+
body = <<~HTML
|
|
174
|
+
<!DOCTYPE html>
|
|
175
|
+
<html>
|
|
176
|
+
<head>
|
|
177
|
+
<meta charset="UTF-8" />
|
|
178
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
179
|
+
<script type="module" src="#{client_url}/tc/control.js"></script>
|
|
180
|
+
</head>
|
|
181
|
+
<body></body>
|
|
182
|
+
</html>
|
|
183
|
+
HTML
|
|
184
|
+
|
|
185
|
+
headers = response_headers("text/html", body)
|
|
186
|
+
headers["content-security-policy"] = "base-uri 'self'; frame-ancestors 'self';"
|
|
187
|
+
[ 200, headers, [ body ] ]
|
|
188
|
+
end
|
|
189
|
+
|
|
109
190
|
def config_endpoint(request)
|
|
110
191
|
json_response(config_data(request), headers: { "access-control-allow-origin" => "*" })
|
|
111
192
|
end
|
|
@@ -154,10 +235,92 @@ class Tidewave
|
|
|
154
235
|
"orm_adapter" => @options[:orm_adapter],
|
|
155
236
|
"team" => @options[:team] || {},
|
|
156
237
|
"tidewave_version" => VERSION,
|
|
157
|
-
"local_port" => local_port(request)
|
|
238
|
+
"local_port" => local_port(request),
|
|
239
|
+
"tmp_dir" => TMP_DIR
|
|
158
240
|
}
|
|
159
241
|
end
|
|
160
242
|
|
|
243
|
+
def inject_toolbar(request, response)
|
|
244
|
+
status, headers, body = response
|
|
245
|
+
return response if @options[:toolbar] == false || !html_response?(headers)
|
|
246
|
+
|
|
247
|
+
if encoded_response?(headers)
|
|
248
|
+
warn_encoded_html
|
|
249
|
+
return response
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
return response unless body.respond_to?(:each)
|
|
253
|
+
|
|
254
|
+
delete_response_header(headers, "content-length")
|
|
255
|
+
delete_response_header(headers, "etag")
|
|
256
|
+
[ status, headers, ToolbarBody.new(body, toolbar_html(request)) ]
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def html_response?(headers)
|
|
260
|
+
content_types = Array(response_header(headers, "content-type"))
|
|
261
|
+
|
|
262
|
+
content_types.any? { |content_type| content_type.to_s.downcase.start_with?("text/html") }
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def encoded_response?(headers)
|
|
266
|
+
Array(response_header(headers, "content-encoding")).any? do |content_encoding|
|
|
267
|
+
content_encoding.to_s.split(",").any? do |encoding|
|
|
268
|
+
!encoding.strip.empty? && encoding.strip.downcase != "identity"
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def response_header(headers, name)
|
|
274
|
+
key = headers.keys.find { |header| header.downcase == name }
|
|
275
|
+
headers[key] if key
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def delete_response_header(headers, name)
|
|
279
|
+
headers.delete_if { |header, _value| header.downcase == name }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def warn_encoded_html
|
|
283
|
+
return if @warned_encoded_html
|
|
284
|
+
|
|
285
|
+
@warned_encoded_html = true
|
|
286
|
+
@logger&.warn(ENCODED_HTML_WARNING)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def toolbar_html(request)
|
|
290
|
+
client_url = @options[:client_url].to_s.sub(%r{/\z}, "")
|
|
291
|
+
payload = {
|
|
292
|
+
"tidewave" => config_data(request),
|
|
293
|
+
"root" => @root.to_s,
|
|
294
|
+
"wsl_distro" => ENV["WSL_DISTRO_NAME"],
|
|
295
|
+
"framework" => {}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
<<~HTML
|
|
299
|
+
<meta name="tidewave:config" content="#{CGI.escapeHTML(JSON.generate(payload))}" />
|
|
300
|
+
<script async type="module" src="#{client_url}/tc/toolbar.js"></script>
|
|
301
|
+
HTML
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def upload_endpoint(request)
|
|
305
|
+
return text_response(400, INVALID_UPLOAD) if upload_too_large?(request)
|
|
306
|
+
|
|
307
|
+
params = request.POST
|
|
308
|
+
type = params["type"]
|
|
309
|
+
upload = normalize_upload(params["file"])
|
|
310
|
+
|
|
311
|
+
unless ALLOWED_UPLOAD_TYPES.include?(type) && allowed_upload?(upload)
|
|
312
|
+
return text_response(400, INVALID_UPLOAD)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
FileUtils.mkdir_p(upload_dir(type))
|
|
316
|
+
destination = upload_path(type, upload[:filename])
|
|
317
|
+
FileUtils.cp(upload[:path], destination)
|
|
318
|
+
|
|
319
|
+
json_response({ "status" => "ok", "path" => relative_path_from_root(destination) })
|
|
320
|
+
rescue ArgumentError
|
|
321
|
+
text_response(400, INVALID_UPLOAD)
|
|
322
|
+
end
|
|
323
|
+
|
|
161
324
|
def json_response(payload, status: 200, headers: {})
|
|
162
325
|
body = JSON.generate(payload)
|
|
163
326
|
[ status, response_headers("application/json", body).merge(headers), [ body ] ]
|
|
@@ -197,7 +360,12 @@ class Tidewave
|
|
|
197
360
|
end
|
|
198
361
|
|
|
199
362
|
def origin_allowed_path?(path)
|
|
200
|
-
|
|
363
|
+
[
|
|
364
|
+
[ TIDEWAVE_ROUTE ],
|
|
365
|
+
[ TIDEWAVE_ROUTE, APP_ROUTE ],
|
|
366
|
+
[ TIDEWAVE_ROUTE, CONFIG_ROUTE ],
|
|
367
|
+
[ TIDEWAVE_ROUTE, UPLOAD_ROUTE ]
|
|
368
|
+
].include?(path)
|
|
201
369
|
end
|
|
202
370
|
|
|
203
371
|
def local_port(request)
|
|
@@ -220,6 +388,67 @@ class Tidewave
|
|
|
220
388
|
false
|
|
221
389
|
end
|
|
222
390
|
|
|
391
|
+
def upload_too_large?(request)
|
|
392
|
+
request.content_length && request.content_length.to_i > MAX_UPLOAD_SIZE
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def normalize_upload(upload)
|
|
396
|
+
case upload
|
|
397
|
+
when Hash
|
|
398
|
+
tempfile = upload[:tempfile] || upload["tempfile"]
|
|
399
|
+
{
|
|
400
|
+
filename: upload[:filename] || upload["filename"],
|
|
401
|
+
content_type: upload[:type] || upload["type"],
|
|
402
|
+
path: tempfile&.path
|
|
403
|
+
}
|
|
404
|
+
else
|
|
405
|
+
return {} unless upload.respond_to?(:original_filename) && upload.respond_to?(:content_type)
|
|
406
|
+
|
|
407
|
+
{
|
|
408
|
+
filename: upload.original_filename,
|
|
409
|
+
content_type: upload.content_type,
|
|
410
|
+
path: upload.tempfile&.path
|
|
411
|
+
}
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def allowed_upload?(upload)
|
|
416
|
+
ALLOWED_UPLOAD_CONTENT_TYPES.include?(upload[:content_type].to_s.split(";").first) &&
|
|
417
|
+
upload[:path] &&
|
|
418
|
+
Tidewave::MagicBytes.type(File.binread(upload[:path], 128)) != :unknown
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def upload_dir(type)
|
|
422
|
+
@root.join(TMP_DIR, "tidewave", folder_for_upload_type(type)).to_s
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def upload_path(type, filename)
|
|
426
|
+
filename = filename.to_s
|
|
427
|
+
|
|
428
|
+
unless filename.match?(/\A[A-Za-z0-9_.-]+\z/) && !filename.include?("..")
|
|
429
|
+
raise ArgumentError, "filename must only contain numbers, letters, hyphens, and underscores: #{filename}"
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
unless [ ".png", ".jpg", ".jpeg", ".webm" ].include?(File.extname(filename).downcase)
|
|
433
|
+
raise ArgumentError, "filename must have a valid extension (.png, .jpg, .jpeg, .webm): #{filename}"
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
File.join(upload_dir(type), filename)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def folder_for_upload_type(type)
|
|
440
|
+
case type
|
|
441
|
+
when "screenshot"
|
|
442
|
+
"screenshots"
|
|
443
|
+
when "recording"
|
|
444
|
+
"recordings"
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def relative_path_from_root(path)
|
|
449
|
+
Pathname.new(path).relative_path_from(@root).to_s
|
|
450
|
+
end
|
|
451
|
+
|
|
223
452
|
def validate_jsonrpc_message(message)
|
|
224
453
|
return "Message must be a JSON object" unless message.is_a?(Hash)
|
|
225
454
|
return "Invalid JSON-RPC version" unless message["jsonrpc"] == "2.0"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tidewave
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yorick Jacquin
|
|
@@ -9,7 +9,7 @@ authors:
|
|
|
9
9
|
autorequire:
|
|
10
10
|
bindir: bin
|
|
11
11
|
cert_chain: []
|
|
12
|
-
date: 2026-07-
|
|
12
|
+
date: 2026-07-22 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: rack
|
|
@@ -41,6 +41,7 @@ files:
|
|
|
41
41
|
- lib/tidewave/database_adapters/active_record.rb
|
|
42
42
|
- lib/tidewave/database_adapters/sequel.rb
|
|
43
43
|
- lib/tidewave/exceptions_middleware.rb
|
|
44
|
+
- lib/tidewave/magic_bytes.rb
|
|
44
45
|
- lib/tidewave/quiet_requests_middleware.rb
|
|
45
46
|
- lib/tidewave/railtie.rb
|
|
46
47
|
- lib/tidewave/tool.rb
|