rails-console 0.1.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.
@@ -0,0 +1,12 @@
1
+ html,
2
+ body {
3
+ background: #000;
4
+ height: 100%;
5
+ margin: 0;
6
+ padding: 0;
7
+ }
8
+
9
+ #rails-console {
10
+ height: 100vh;
11
+ width: 100vw;
12
+ }
@@ -0,0 +1,135 @@
1
+ import { createConsumer } from '@rails/actioncable';
2
+ import { FitAddon } from '@xterm/addon-fit';
3
+ import { Terminal } from '@xterm/xterm';
4
+
5
+ const RELOAD_MARKER = '\x00CONSOLE_RELOAD\x00';
6
+ const RETRY_DELAY = 3000;
7
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
8
+ const SPINNER_INTERVAL = 90;
9
+
10
+ class RailsConsoleTerminal {
11
+ constructor(element) {
12
+ this.element = element;
13
+ this.consumer = createConsumer();
14
+ }
15
+
16
+ connect() {
17
+ this.openTerminal();
18
+ this.subscribe();
19
+ }
20
+
21
+ disconnect() {
22
+ window.removeEventListener('resize', this.onResize);
23
+
24
+ clearTimeout(this.retryTimeout);
25
+ this.stopSpinner();
26
+
27
+ this.subscription?.unsubscribe();
28
+ this.terminal?.dispose();
29
+ }
30
+
31
+ onData(data) {
32
+ const bytes = new TextEncoder().encode(data);
33
+ const binary = String.fromCharCode(...bytes);
34
+
35
+ this.subscription.perform('receive', { bytes: btoa(binary) });
36
+ }
37
+
38
+ openTerminal() {
39
+ this.fitAddon = new FitAddon();
40
+ this.terminal = new Terminal({ cursorBlink: true });
41
+
42
+ this.terminal.loadAddon(this.fitAddon);
43
+ this.terminal.open(this.element);
44
+ this.fitAddon.fit();
45
+ this.terminal.focus();
46
+
47
+ this.onResize = () => this.fitAddon.fit();
48
+
49
+ window.addEventListener('resize', this.onResize);
50
+
51
+ this.terminal.onData((data) => this.onData(data));
52
+ }
53
+
54
+ received(data) {
55
+ if (data.error) {
56
+ this.stopSpinner();
57
+ this.showError(data.error);
58
+ } else if (data.bytes) {
59
+ this.writeBytes(data.bytes);
60
+ }
61
+ }
62
+
63
+ showError(message) {
64
+ this.terminal.write(`\r\n${message}\r\n`);
65
+
66
+ this.subscription.unsubscribe();
67
+
68
+ this.retryTimeout = setTimeout(() => this.subscribe(), RETRY_DELAY);
69
+ }
70
+
71
+ startSpinner() {
72
+ let frame = 0;
73
+
74
+ this.spinner = setInterval(() => {
75
+ this.terminal.write(`\r\x1b[K\x1b[33m${SPINNER_FRAMES[frame]}\x1b[0m Booting console…`);
76
+
77
+ frame = (frame + 1) % SPINNER_FRAMES.length;
78
+ }, SPINNER_INTERVAL);
79
+ }
80
+
81
+ stopSpinner() {
82
+ if (!this.spinner) {
83
+ return;
84
+ }
85
+
86
+ clearInterval(this.spinner);
87
+
88
+ this.spinner = null;
89
+
90
+ this.terminal.write('\r\x1b[K');
91
+ }
92
+
93
+ subscribe() {
94
+ this.startSpinner();
95
+
96
+ this.subscription = this.consumer.subscriptions.create('RailsConsole::ConsoleChannel', {
97
+ connected: () => {},
98
+ disconnected: () => {},
99
+ received: (data) => this.received(data),
100
+ });
101
+ }
102
+
103
+ writeBytes(encoded) {
104
+ let binary = atob(encoded);
105
+
106
+ // The broker sends this marker when it swaps the session (e.g. on unsafe!). Reset the
107
+ // screen and show the booting spinner again until the fresh console prints its output.
108
+ if (binary.includes(RELOAD_MARKER)) {
109
+ binary = binary.replaceAll(RELOAD_MARKER, '');
110
+
111
+ this.terminal.reset();
112
+ this.startSpinner();
113
+ } else {
114
+ this.stopSpinner();
115
+ }
116
+
117
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
118
+
119
+ this.terminal.write(bytes);
120
+ }
121
+ }
122
+
123
+ document.addEventListener('DOMContentLoaded', () => {
124
+ const element = document.querySelector('[data-rails-console]');
125
+
126
+ if (!element) {
127
+ return;
128
+ }
129
+
130
+ const terminal = new RailsConsoleTerminal(element);
131
+
132
+ terminal.connect();
133
+
134
+ window.addEventListener('beforeunload', () => terminal.disconnect());
135
+ });
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'socket'
5
+
6
+ module RailsConsole
7
+ class ConsoleChannel < ActionCable::Channel::Base
8
+ # Keystrokes from the browser: decode and forward them to the broker.
9
+ def receive(data)
10
+ bytes = Base64.strict_decode64(data.fetch('bytes'))
11
+
12
+ @socket&.write(bytes)
13
+ rescue ArgumentError, Errno::EPIPE, IOError
14
+ nil
15
+ end
16
+
17
+ # Connects an authorized user to the broker and starts streaming its output back.
18
+ def subscribed
19
+ return reject if !RailsConsole.authorized?(connection)
20
+
21
+ return reject_unavailable if !RailsConsole::Broker.boot
22
+
23
+ @socket = connect_to_broker
24
+ @reader_thread = Thread.new { relay_broker_output }
25
+ rescue Errno::ECONNREFUSED, Errno::ENOENT
26
+ reject_unavailable
27
+ end
28
+
29
+ # Closes the broker connection when the browser leaves.
30
+ def unsubscribed
31
+ @reader_thread&.kill
32
+ @socket&.close
33
+ rescue IOError
34
+ nil
35
+ end
36
+
37
+ private
38
+
39
+ # Opens the broker socket and sends who is connecting.
40
+ def connect_to_broker
41
+ socket = UNIXSocket.new(RailsConsole.socket_path.to_s)
42
+ identity = RailsConsole.config.current_user.call(RailsConsole.user_from(connection))
43
+ ip_address = Rack::Request.new(connection.env).ip
44
+ label = identity[:label] || identity['label']
45
+ user_id = identity[:id] || identity['id']
46
+
47
+ socket.puts(JSON.generate(ip_address:, label:, user_id:))
48
+
49
+ socket
50
+ end
51
+
52
+ # Tells the browser the console could not be reached and drops the subscription.
53
+ def reject_unavailable
54
+ transmit({ error: 'Console is unavailable at the moment.' })
55
+
56
+ reject
57
+ end
58
+
59
+ # Streams broker output to the browser, encoded so binary bytes survive JSON.
60
+ def relay_broker_output
61
+ loop do
62
+ bytes = @socket.readpartial(RailsConsole::PtySession::READ_SIZE)
63
+
64
+ transmit({ bytes: Base64.strict_encode64(bytes) })
65
+ end
66
+ rescue IOError
67
+ nil
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsConsole
4
+ class ConsolesController < ActionController::Base
5
+ layout 'rails_console/application'
6
+
7
+ before_action :authorize!
8
+
9
+ def show; end
10
+
11
+ private
12
+
13
+ def authorize!
14
+ return if RailsConsole.authorized?(request)
15
+
16
+ head(403)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsConsole
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsConsole
4
+ class LogLine < ApplicationRecord
5
+ self.table_name = 'rails_console_log_lines'
6
+
7
+ enum :direction, { input: 0, output: 1, transition: 2 }
8
+
9
+ belongs_to :session, class_name: 'RailsConsole::Session', inverse_of: :log_lines, optional: false
10
+
11
+ validates :content, presence: true
12
+ end
13
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsConsole
4
+ class Session < ApplicationRecord
5
+ self.table_name = 'rails_console_sessions'
6
+
7
+ has_many :log_lines, class_name: 'RailsConsole::LogLine', dependent: :destroy, inverse_of: :session
8
+
9
+ validates :started_at, presence: true
10
+
11
+ def user
12
+ return if RailsConsole.user_class.blank?
13
+
14
+ model = RailsConsole.user_class.safe_constantize
15
+
16
+ return if model.nil?
17
+
18
+ model.find_by(id: user_id)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,16 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Rails Console</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= action_cable_meta_tag %>
8
+
9
+ <%= stylesheet_link_tag 'rails_console', nonce: true %>
10
+ <%= javascript_include_tag 'rails_console', nonce: true %>
11
+ </head>
12
+
13
+ <body>
14
+ <%= yield %>
15
+ </body>
16
+ </html>
@@ -0,0 +1 @@
1
+ <div id="rails-console" data-rails-console></div>
data/bin/rails_console ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ abort('Run this from a Rails app root (bundle exec rails_console).') if !File.exist?('config/environment.rb')
5
+
6
+ require File.expand_path('config/environment', Dir.pwd)
7
+
8
+ RailsConsole::Broker.new.run
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ RailsConsole::Engine.routes.draw do
4
+ root to: 'consoles#show'
5
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsConsole
4
+ class InstallGenerator < Rails::Generators::Base
5
+ source_root File.expand_path('templates', __dir__)
6
+
7
+ desc 'Installs RailsConsole migrations and initializer'
8
+
9
+ def create_initializer
10
+ template('rails_console.rb', 'config/initializers/rails_console.rb')
11
+ end
12
+
13
+ def create_migrations
14
+ template(
15
+ 'db/migrate/create_rails_console_sessions.rb',
16
+ "db/migrate/#{timestamp(0)}_create_rails_console_sessions.rb"
17
+ )
18
+
19
+ template(
20
+ 'db/migrate/create_rails_console_log_lines.rb',
21
+ "db/migrate/#{timestamp(1)}_create_rails_console_log_lines.rb"
22
+ )
23
+ end
24
+
25
+ def link_sprockets_assets
26
+ manifest = 'app/assets/config/manifest.js'
27
+
28
+ return if !File.exist?(manifest)
29
+
30
+ contents = File.read(manifest)
31
+
32
+ return if contents.include?('rails_console.css')
33
+
34
+ append_to_file(manifest) do
35
+ <<~JS
36
+
37
+ //= link rails_console.css
38
+ //= link rails_console.js
39
+ JS
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def migration_version
46
+ ActiveRecord::Migration.current_version
47
+ rescue StandardError
48
+ '8.1'
49
+ end
50
+
51
+ def timestamp(offset)
52
+ Time.current.utc.strftime('%Y%m%d%H%M%S').to_i + offset
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateRailsConsoleLogLines < ActiveRecord::Migration[<%= migration_version %>]
4
+ def change
5
+ create_table :rails_console_log_lines do |t|
6
+ t.integer :direction, null: false, default: 0
7
+ t.text :content, null: false
8
+
9
+ t.references :session, foreign_key: { to_table: :rails_console_sessions }, null: false
10
+
11
+ t.timestamps
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateRailsConsoleSessions < ActiveRecord::Migration[<%= migration_version %>]
4
+ def change
5
+ create_table :rails_console_sessions do |t|
6
+ t.integer :user_id
7
+ t.string :user_label
8
+ t.string :ip_address
9
+ t.datetime :started_at, null: false
10
+ t.datetime :ended_at
11
+ t.datetime :unsafe_at
12
+
13
+ t.timestamps
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ RailsConsole.configure do |config|
4
+ config.audit = true
5
+
6
+ config.authorize = ->(user) { user&.devops? }
7
+
8
+ config.command = 'bundle exec rails console'
9
+
10
+ config.current_user = ->(user) { { id: user&.id, label: user.try(:email) || 'unknown' } }
11
+
12
+ config.idle_timeout = 10.minutes
13
+ config.sandbox_command = 'bundle exec rails console --sandbox'
14
+ end