rails-markup 1.2.4 → 1.3.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/app/assets/javascripts/rails_markup/toolbar.js +68 -9
- data/app/controllers/rails_markup/dashboard_controller.rb +14 -2
- data/app/controllers/rails_markup/external/annotations_controller.rb +5 -2
- data/app/models/rails_markup/annotation.rb +15 -8
- data/app/views/rails_markup/shared/_toolbar.html.erb +14 -2
- data/lib/generators/rails_markup/install/templates/create_rails_markup_annotations.rb.erb +11 -2
- data/lib/generators/rails_markup/install_generator.rb +13 -2
- data/lib/rails_markup/cli.rb +22 -6
- data/lib/rails_markup/configuration.rb +8 -0
- data/lib/rails_markup/http_server.rb +8 -1
- data/lib/rails_markup/server.rb +5 -4
- data/lib/rails_markup/store.rb +11 -0
- data/lib/rails_markup/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d852927076b8b5e1774675692c45b575eb9dd6337c934637e90c693310d96f4d
|
|
4
|
+
data.tar.gz: 3608bdd64056b7cb3373de2cd4ea0797a1e6dc3e021457ec96697a4d3f8c7919
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ac1ee4649e024d9a3fa306bf3fefb9f6443c2d89cec4d33de6b504831123228f1db3cc2e20414914fdec41150e832ea077d76cfe1c606859c2177626eb643251
|
|
7
|
+
data.tar.gz: e8942c6bcbff10487ab62170039b6c62c0c0ffe87a59cb77ac8df87792c6365109d222451fc8756831fad1284b1336f7d0adff3dbf3698b257e2c0e2b1346899
|
|
@@ -381,7 +381,7 @@
|
|
|
381
381
|
this._boundMouseUp = (e) => self._handleMouseUp(e);
|
|
382
382
|
this._boundClick = (e) => self._handleClick(e);
|
|
383
383
|
this._boundKeyDown = (e) => self._handleKeyDown(e);
|
|
384
|
-
this._boundTouchStart = (e) => { if (self.active && e.touches[0]) { const t = e.touches[0]; self._handleMouseDown({ clientX: t.clientX, clientY: t.clientY }); } };
|
|
384
|
+
this._boundTouchStart = (e) => { if (self.active && e.touches[0]) { const t = e.touches[0]; const el = document.elementFromPoint(t.clientX, t.clientY); if (el && !self._isToolbar(el) && e.cancelable) e.preventDefault(); self._handleMouseDown({ clientX: t.clientX, clientY: t.clientY }); } };
|
|
385
385
|
this._boundTouchEnd = (e) => { if (self.active && e.changedTouches[0]) { const t = e.changedTouches[0]; const el = document.elementFromPoint(t.clientX, t.clientY); if (el && !self._isToolbar(el)) { e.preventDefault(); self._handleMouseUp({ clientX: t.clientX, clientY: t.clientY, preventDefault(){}, stopPropagation(){} }); } } };
|
|
386
386
|
|
|
387
387
|
// Turbo Frames — partial DOM update, reposition pins
|
|
@@ -512,7 +512,14 @@
|
|
|
512
512
|
|
|
513
513
|
_handleMouseDown(event) {
|
|
514
514
|
const el = document.elementFromPoint(event.clientX, event.clientY);
|
|
515
|
-
if (el && !this._isToolbar(el))
|
|
515
|
+
if (el && !this._isToolbar(el)) {
|
|
516
|
+
this.clickedElement = el;
|
|
517
|
+
// Suppress the press so host controls (buttons, drag handles, form
|
|
518
|
+
// fields) don't act before mouseup/click is blocked. Guard for the
|
|
519
|
+
// synthetic object passed from the touchstart handler.
|
|
520
|
+
if (typeof event.preventDefault === "function") event.preventDefault();
|
|
521
|
+
if (typeof event.stopPropagation === "function") event.stopPropagation();
|
|
522
|
+
}
|
|
516
523
|
},
|
|
517
524
|
|
|
518
525
|
async _handleMouseUp(event) {
|
|
@@ -1035,13 +1042,17 @@
|
|
|
1035
1042
|
|
|
1036
1043
|
// ---- Storage ----
|
|
1037
1044
|
|
|
1038
|
-
_storageKey() {
|
|
1045
|
+
_storageKey() {
|
|
1046
|
+
const endpoint = (this.endpoint || "/feedback/api").replace(/\/+$/, "") || "/";
|
|
1047
|
+
return `rm-annotations:${encodeURIComponent(endpoint)}`;
|
|
1048
|
+
},
|
|
1039
1049
|
_pageUrl() { return window.location.pathname + window.location.search; },
|
|
1040
|
-
_pageStorageKey() { return "
|
|
1050
|
+
_pageStorageKey() { return this._storageKey() + ":" + this._pageUrl(); },
|
|
1041
1051
|
|
|
1042
1052
|
_saveToStorage() {
|
|
1043
1053
|
try {
|
|
1044
|
-
//
|
|
1054
|
+
// Cross-tab storage-event merging is deferred: safely reconciling ordered
|
|
1055
|
+
// upserts and tombstones needs conflict semantics, not a last-write merge.
|
|
1045
1056
|
localStorage.setItem(this._storageKey(), JSON.stringify({
|
|
1046
1057
|
annotations: this.annotations,
|
|
1047
1058
|
nextId: this.nextId,
|
|
@@ -1436,7 +1447,8 @@
|
|
|
1436
1447
|
|
|
1437
1448
|
_loadFromStorage() {
|
|
1438
1449
|
try {
|
|
1439
|
-
//
|
|
1450
|
+
// Old unnamespaced keys are intentionally not read because they may belong
|
|
1451
|
+
// to another user or toolbar mount on the same origin.
|
|
1440
1452
|
let raw = localStorage.getItem(this._storageKey());
|
|
1441
1453
|
if (raw) {
|
|
1442
1454
|
const data = JSON.parse(raw);
|
|
@@ -1459,8 +1471,8 @@
|
|
|
1459
1471
|
},
|
|
1460
1472
|
|
|
1461
1473
|
_migratePageAnnotations() {
|
|
1462
|
-
// Find and merge
|
|
1463
|
-
const prefix = "
|
|
1474
|
+
// Find and merge per-page annotation keys only within this endpoint namespace.
|
|
1475
|
+
const prefix = this._storageKey() + ":";
|
|
1464
1476
|
const migratedKeys = [];
|
|
1465
1477
|
const seenIds = new Set(this.annotations.map(a => a.id));
|
|
1466
1478
|
const consolidatedClientIds = new Set(this.annotations.map(a => a.clientId).filter(clientId => this._validClientId(clientId)));
|
|
@@ -1537,6 +1549,8 @@
|
|
|
1537
1549
|
return { annotation, index };
|
|
1538
1550
|
});
|
|
1539
1551
|
|
|
1552
|
+
this._normalizeOutboxEnvelopes();
|
|
1553
|
+
|
|
1540
1554
|
const byClientId = new Map();
|
|
1541
1555
|
normalized.forEach(candidate => {
|
|
1542
1556
|
const current = byClientId.get(candidate.annotation.clientId);
|
|
@@ -1549,7 +1563,10 @@
|
|
|
1549
1563
|
this._assignDisplayIds();
|
|
1550
1564
|
this.annotations.forEach(annotation => {
|
|
1551
1565
|
const mapped = annotation.serverId != null;
|
|
1552
|
-
const
|
|
1566
|
+
const queuedEntry = this.outbox[annotation.clientId];
|
|
1567
|
+
const queued = Boolean(queuedEntry);
|
|
1568
|
+
const annotationRevision = Number.isInteger(annotation.revision) && annotation.revision >= 0 ? annotation.revision : 0;
|
|
1569
|
+
annotation.revision = annotationRevision;
|
|
1553
1570
|
annotation.syncState = (queued && annotation.syncState === "failed")
|
|
1554
1571
|
? "failed"
|
|
1555
1572
|
: ((queued || !mapped) ? "pending" : "synced");
|
|
@@ -1557,6 +1574,9 @@
|
|
|
1557
1574
|
annotation.dirtyFields = this._legacyDirtyFields(annotation);
|
|
1558
1575
|
this.outbox[annotation.clientId] = {
|
|
1559
1576
|
type: "upsert",
|
|
1577
|
+
clientId: annotation.clientId,
|
|
1578
|
+
revision: annotation.revision,
|
|
1579
|
+
syncState: "pending",
|
|
1560
1580
|
annotation: this._desiredState(annotation),
|
|
1561
1581
|
dirtyFields: annotation.dirtyFields.slice()
|
|
1562
1582
|
};
|
|
@@ -1564,6 +1584,45 @@
|
|
|
1564
1584
|
});
|
|
1565
1585
|
},
|
|
1566
1586
|
|
|
1587
|
+
_normalizeOutboxEnvelopes() {
|
|
1588
|
+
const normalized = {};
|
|
1589
|
+
|
|
1590
|
+
Object.entries(this.outbox).forEach(([storedClientId, candidate]) => {
|
|
1591
|
+
if (!this._plainObject(candidate)) return;
|
|
1592
|
+
|
|
1593
|
+
const nestedClientId = candidate.annotation?.clientId;
|
|
1594
|
+
const clientId = this._validClientId(nestedClientId)
|
|
1595
|
+
? nestedClientId
|
|
1596
|
+
: (this._validClientId(candidate.clientId)
|
|
1597
|
+
? candidate.clientId
|
|
1598
|
+
: (this._validClientId(storedClientId) ? storedClientId : null));
|
|
1599
|
+
if (!clientId) return;
|
|
1600
|
+
|
|
1601
|
+
const type = candidate.type === "delete"
|
|
1602
|
+
? "delete"
|
|
1603
|
+
: ((candidate.type === "upsert" || this._plainObject(candidate.annotation)) ? "upsert" : null);
|
|
1604
|
+
if (!type) return;
|
|
1605
|
+
|
|
1606
|
+
const annotation = this.annotations.find(record => record.clientId === clientId);
|
|
1607
|
+
const candidateRevision = Number.isInteger(candidate.revision) && candidate.revision >= 0 ? candidate.revision : 0;
|
|
1608
|
+
const annotationRevision = Number.isInteger(annotation?.revision) && annotation.revision >= 0 ? annotation.revision : 0;
|
|
1609
|
+
const envelope = Object.assign({}, candidate, {
|
|
1610
|
+
type,
|
|
1611
|
+
clientId,
|
|
1612
|
+
revision: Math.max(candidateRevision, annotationRevision),
|
|
1613
|
+
syncState: candidate.syncState === "failed" ? "failed" : "pending"
|
|
1614
|
+
});
|
|
1615
|
+
|
|
1616
|
+
if (type === "upsert") {
|
|
1617
|
+
envelope.annotation = Object.assign({}, candidate.annotation, { clientId });
|
|
1618
|
+
envelope.dirtyFields = this._mergeDirtyFields(candidate.dirtyFields || envelope.annotation.dirtyFields || []);
|
|
1619
|
+
}
|
|
1620
|
+
normalized[clientId] = envelope;
|
|
1621
|
+
});
|
|
1622
|
+
|
|
1623
|
+
this.outbox = normalized;
|
|
1624
|
+
},
|
|
1625
|
+
|
|
1567
1626
|
_isNewerLocalRecord(candidate, current) {
|
|
1568
1627
|
const timestamp = value => {
|
|
1569
1628
|
const parsed = Date.parse(value || "");
|
|
@@ -223,10 +223,22 @@ module RailsMarkup
|
|
|
223
223
|
# each (not find_each) so the export keeps the scope's :recent ordering —
|
|
224
224
|
# find_each ignores ORDER BY and batches by primary key.
|
|
225
225
|
scope.each do |ann|
|
|
226
|
-
csv << [ann.id, ann.status, ann.intent, ann.severity,
|
|
227
|
-
ann.
|
|
226
|
+
csv << [ann.id, ann.status, ann.intent, ann.severity,
|
|
227
|
+
csv_safe(ann.content), csv_safe(ann.page_url),
|
|
228
|
+
csv_safe(ann.author_name), csv_safe(ann.selected_text),
|
|
229
|
+
ann.created_at.iso8601, ann.updated_at.iso8601]
|
|
228
230
|
end
|
|
229
231
|
end
|
|
230
232
|
end
|
|
233
|
+
|
|
234
|
+
# Neutralize CSV/spreadsheet formula injection: a leading =, +, -, @, or
|
|
235
|
+
# control char makes Excel/Sheets evaluate the cell as a formula. Prefix
|
|
236
|
+
# such values with an apostrophe so they're treated as literal text.
|
|
237
|
+
def csv_safe(value)
|
|
238
|
+
str = value.to_s
|
|
239
|
+
return str unless str.match?(/\A[=+\-@\t\r]/)
|
|
240
|
+
|
|
241
|
+
"'#{str}"
|
|
242
|
+
end
|
|
231
243
|
end
|
|
232
244
|
end
|
|
@@ -54,8 +54,11 @@ module RailsMarkup
|
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
def authenticate_token!
|
|
57
|
-
# Development
|
|
58
|
-
|
|
57
|
+
# Development convenience: skip token auth so you can reach the API from
|
|
58
|
+
# another device on your LAN. This permits unauthenticated reads and
|
|
59
|
+
# writes from any network peer — set
|
|
60
|
+
# config.require_api_token_in_development to lock it down.
|
|
61
|
+
return if Rails.env.development? && !RailsMarkup.config.require_api_token_in_development
|
|
59
62
|
|
|
60
63
|
token = RailsMarkup.config.api_token
|
|
61
64
|
return head(:not_found) if token.nil?
|
|
@@ -114,28 +114,35 @@ module RailsMarkup
|
|
|
114
114
|
end
|
|
115
115
|
|
|
116
116
|
def resolve!(summary: nil)
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
# with_lock reloads under a row lock so a concurrent reply/resolve can't
|
|
118
|
+
# read a stale thread and silently drop the other write on save.
|
|
119
|
+
with_lock do
|
|
120
|
+
return self if status == "resolved" # idempotent — re-resolving is a no-op
|
|
121
|
+
raise "Cannot resolve a #{status} annotation" unless status.in?(%w[pending acknowledged])
|
|
119
122
|
|
|
120
|
-
transaction do
|
|
121
123
|
add_thread_entry(role: "agent", message: summary) if summary.present?
|
|
122
124
|
update!(status: "resolved")
|
|
123
125
|
end
|
|
126
|
+
self
|
|
124
127
|
end
|
|
125
128
|
|
|
126
129
|
def dismiss!(reason: nil)
|
|
127
|
-
|
|
128
|
-
|
|
130
|
+
with_lock do
|
|
131
|
+
return self if status == "dismissed" # idempotent — re-dismissing is a no-op
|
|
132
|
+
raise "Cannot dismiss a #{status} annotation" unless status.in?(%w[pending acknowledged])
|
|
129
133
|
|
|
130
|
-
transaction do
|
|
131
134
|
add_thread_entry(role: "agent", message: reason) if reason.present?
|
|
132
135
|
update!(status: "dismissed")
|
|
133
136
|
end
|
|
137
|
+
self
|
|
134
138
|
end
|
|
135
139
|
|
|
136
140
|
def add_reply!(message:, role: "agent")
|
|
137
|
-
|
|
138
|
-
|
|
141
|
+
with_lock do
|
|
142
|
+
add_thread_entry(role: role, message: message)
|
|
143
|
+
save!
|
|
144
|
+
end
|
|
145
|
+
self
|
|
139
146
|
end
|
|
140
147
|
|
|
141
148
|
def as_api_json
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
<%# Rails Markup annotation toolbar — self-contained, no Tailwind/Stimulus dependency %>
|
|
2
2
|
<% if RailsMarkup.config.toolbar_enabled %>
|
|
3
|
+
<%# Authorization sentinel: present only when this partial renders (i.e. the
|
|
4
|
+
layout gate authorized the request). The turbo:load handler below checks for
|
|
5
|
+
it after each navigation and tears the toolbar down if it's gone — otherwise
|
|
6
|
+
the document-level listener would recreate the toolbar (and expose cached
|
|
7
|
+
annotations) after a logout Turbo visit whose new body omits the partial. %>
|
|
8
|
+
<span id="rm-toolbar-gate" hidden aria-hidden="true"></span>
|
|
3
9
|
<script>
|
|
4
10
|
<%== File.read(File.expand_path("../../../assets/javascripts/rails_markup/toolbar.js", __dir__)) %>
|
|
5
11
|
</script>
|
|
@@ -19,9 +25,15 @@
|
|
|
19
25
|
};
|
|
20
26
|
// Init immediately (DOM is ready — script is at end of body)
|
|
21
27
|
RailsMarkupToolbar.init(opts);
|
|
22
|
-
//
|
|
28
|
+
// After Turbo Drive navigations (DOMContentLoaded won't fire again), only
|
|
29
|
+
// re-init when the new page is still authorized (gate sentinel present);
|
|
30
|
+
// otherwise tear the toolbar down so it can't persist past a logout.
|
|
23
31
|
document.addEventListener("turbo:load", function() {
|
|
24
|
-
|
|
32
|
+
if (document.getElementById("rm-toolbar-gate")) {
|
|
33
|
+
RailsMarkupToolbar.init(opts);
|
|
34
|
+
} else if (window.RailsMarkupToolbar && typeof RailsMarkupToolbar.destroy === "function") {
|
|
35
|
+
RailsMarkupToolbar.destroy();
|
|
36
|
+
}
|
|
25
37
|
});
|
|
26
38
|
})();
|
|
27
39
|
</script>
|
|
@@ -5,7 +5,10 @@ class CreateRailsMarkupAnnotations < ActiveRecord::Migration<%= migration_versio
|
|
|
5
5
|
|
|
6
6
|
create_table :<%= options[:table_name] %> do |t|
|
|
7
7
|
t.bigint :user_id
|
|
8
|
-
|
|
8
|
+
# 2048 to match the model's page_url length validation. The default
|
|
9
|
+
# string limit (255) would raise DB errors on PostgreSQL/MySQL for
|
|
10
|
+
# model-valid long URLs (SQLite is lax, so tests wouldn't catch it).
|
|
11
|
+
t.string :page_url, limit: 2048, null: false
|
|
9
12
|
t.send json_type, :target, default: {}
|
|
10
13
|
t.text :content, null: false
|
|
11
14
|
t.string :intent, null: false, default: "change"
|
|
@@ -20,7 +23,13 @@ class CreateRailsMarkupAnnotations < ActiveRecord::Migration<%= migration_versio
|
|
|
20
23
|
end
|
|
21
24
|
|
|
22
25
|
add_index :<%= options[:table_name] %>, [:status, :created_at]
|
|
23
|
-
|
|
26
|
+
# A full 2048-char index blows past MySQL/InnoDB's key-length limit on
|
|
27
|
+
# utf8mb4, so index only a prefix there; other adapters index the column.
|
|
28
|
+
if connection.adapter_name.downcase.include?("mysql")
|
|
29
|
+
add_index :<%= options[:table_name] %>, :page_url, length: 191
|
|
30
|
+
else
|
|
31
|
+
add_index :<%= options[:table_name] %>, :page_url
|
|
32
|
+
end
|
|
24
33
|
add_index :<%= options[:table_name] %>, :user_id
|
|
25
34
|
add_index :<%= options[:table_name] %>, :client_uuid, unique: true
|
|
26
35
|
end
|
|
@@ -70,8 +70,19 @@ module RailsMarkup
|
|
|
70
70
|
<% end %>
|
|
71
71
|
ERB
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
content = File.read(File.join(destination_root, layout_path))
|
|
74
|
+
|
|
75
|
+
if content.include?("rails_markup/shared/toolbar")
|
|
76
|
+
# Upgrade the pre-1.2.3 partial-existence gate, which rendered the
|
|
77
|
+
# toolbar for every visitor, to the admin-gated block. Leave any
|
|
78
|
+
# custom (hand-edited) block untouched so we don't clobber it.
|
|
79
|
+
legacy = /^[ \t]*<%#\s*Rails Markup annotation toolbar\s*%>\r?\n[ \t]*<%\s*if\s+lookup_context\.exists\?.*?<%\s*end\s*%>\r?\n?/m
|
|
80
|
+
if content =~ legacy
|
|
81
|
+
gsub_file layout_path, legacy, toolbar_block
|
|
82
|
+
say_status :update, "upgraded toolbar to admin-gated render in #{layout_path}", :green
|
|
83
|
+
else
|
|
84
|
+
say_status :skip, "toolbar already present in #{layout_path} (left as-is)", :yellow
|
|
85
|
+
end
|
|
75
86
|
return
|
|
76
87
|
end
|
|
77
88
|
|
data/lib/rails_markup/cli.rb
CHANGED
|
@@ -39,8 +39,10 @@ module RailsMarkup
|
|
|
39
39
|
bin/markup server --port 5000 # custom port
|
|
40
40
|
DESC
|
|
41
41
|
method_option :port, type: :numeric, default: 4747, desc: "HTTP server port"
|
|
42
|
+
method_option :host, type: :string, default: "127.0.0.1",
|
|
43
|
+
desc: "Bind address (loopback by default; use 0.0.0.0 to expose on the LAN)"
|
|
42
44
|
def server
|
|
43
|
-
srv = RailsMarkup::Server.new(port: options[:port])
|
|
45
|
+
srv = RailsMarkup::Server.new(port: options[:port], bind: options[:host])
|
|
44
46
|
srv.start
|
|
45
47
|
end
|
|
46
48
|
|
|
@@ -641,17 +643,25 @@ module RailsMarkup
|
|
|
641
643
|
"local"
|
|
642
644
|
end
|
|
643
645
|
|
|
644
|
-
#
|
|
646
|
+
# Merge configs per-key with local taking precedence over global over codex.
|
|
647
|
+
# Merging (rather than returning the first non-empty config) means a local
|
|
648
|
+
# config that only defines dev values doesn't shadow production credentials
|
|
649
|
+
# stored globally or in the codex config.
|
|
645
650
|
def resolve_mcp_env
|
|
646
|
-
|
|
651
|
+
merged = {}
|
|
652
|
+
# Reverse so the highest-precedence scope (local) is applied last and wins.
|
|
653
|
+
McpConfig::SCOPES.reverse_each do |scope|
|
|
647
654
|
config = McpConfig.new(scope: scope)
|
|
648
655
|
next unless config.exist?
|
|
649
656
|
|
|
650
|
-
|
|
651
|
-
|
|
657
|
+
# Skip blank values so an empty key in a higher-precedence config
|
|
658
|
+
# doesn't clobber a real value from a lower-precedence one.
|
|
659
|
+
config.raw_env.each do |key, value|
|
|
660
|
+
merged[key] = value unless value.to_s.strip.empty?
|
|
661
|
+
end
|
|
652
662
|
end
|
|
653
663
|
|
|
654
|
-
|
|
664
|
+
merged
|
|
655
665
|
end
|
|
656
666
|
|
|
657
667
|
def resolve_env(production)
|
|
@@ -674,6 +684,12 @@ module RailsMarkup
|
|
|
674
684
|
return nil
|
|
675
685
|
end
|
|
676
686
|
|
|
687
|
+
unless base_url.to_s.downcase.start_with?("https://")
|
|
688
|
+
say "Refusing to send the production token over an insecure connection.", :red
|
|
689
|
+
say "Production URL must use HTTPS (got: #{base_url})", :red
|
|
690
|
+
return nil
|
|
691
|
+
end
|
|
692
|
+
|
|
677
693
|
{ base_url: base_url, token: token, mount_path: mount }
|
|
678
694
|
else
|
|
679
695
|
base_url = options[:url] || mcp_env["RAILS_MARKUP_DEV_URL"]
|
|
@@ -15,6 +15,13 @@ module RailsMarkup
|
|
|
15
15
|
# Set to nil to disable external API.
|
|
16
16
|
attr_accessor :api_token
|
|
17
17
|
|
|
18
|
+
# In development the external API skips token auth by default so you can
|
|
19
|
+
# reach it from another device on your LAN (e.g. your phone). That allows
|
|
20
|
+
# unauthenticated reads AND writes from any network peer. Set to true to
|
|
21
|
+
# require the bearer token even in development.
|
|
22
|
+
# Default: false (open in development)
|
|
23
|
+
attr_accessor :require_api_token_in_development
|
|
24
|
+
|
|
18
25
|
# Database table name for annotations.
|
|
19
26
|
attr_accessor :table_name
|
|
20
27
|
|
|
@@ -77,6 +84,7 @@ module RailsMarkup
|
|
|
77
84
|
def initialize
|
|
78
85
|
@base_controller_class = "RailsMarkup::ApplicationController"
|
|
79
86
|
@api_token = nil
|
|
87
|
+
@require_api_token_in_development = false
|
|
80
88
|
@table_name = "rails_markup_annotations"
|
|
81
89
|
@per_page = 25
|
|
82
90
|
@toolbar_accent = "indigo"
|
|
@@ -9,15 +9,22 @@ module RailsMarkup
|
|
|
9
9
|
class HttpServer
|
|
10
10
|
attr_reader :port, :store
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
attr_reader :bind
|
|
13
|
+
|
|
14
|
+
def initialize(store:, port: 4747, bind: "127.0.0.1", logger: nil)
|
|
13
15
|
@store = store
|
|
14
16
|
@port = port
|
|
17
|
+
@bind = bind
|
|
15
18
|
@logger = logger || WEBrick::Log.new($stderr, WEBrick::Log::WARN)
|
|
16
19
|
end
|
|
17
20
|
|
|
18
21
|
def start
|
|
22
|
+
# Bind to loopback by default — this store server is unauthenticated, so
|
|
23
|
+
# binding to 0.0.0.0 would expose it to the whole LAN. Pass bind: "0.0.0.0"
|
|
24
|
+
# (bin/markup server --host 0.0.0.0) to deliberately expose it.
|
|
19
25
|
@server = WEBrick::HTTPServer.new(
|
|
20
26
|
Port: @port,
|
|
27
|
+
BindAddress: @bind,
|
|
21
28
|
Logger: @logger,
|
|
22
29
|
AccessLog: [],
|
|
23
30
|
DoNotReverseLookup: true
|
data/lib/rails_markup/server.rb
CHANGED
|
@@ -9,8 +9,9 @@ module RailsMarkup
|
|
|
9
9
|
class Server
|
|
10
10
|
attr_reader :store
|
|
11
11
|
|
|
12
|
-
def initialize(port: 4747, mcp_only: false)
|
|
12
|
+
def initialize(port: 4747, bind: "127.0.0.1", mcp_only: false)
|
|
13
13
|
@port = port
|
|
14
|
+
@bind = bind
|
|
14
15
|
@mcp_only = mcp_only
|
|
15
16
|
@store = Store.new
|
|
16
17
|
end
|
|
@@ -44,8 +45,8 @@ module RailsMarkup
|
|
|
44
45
|
if port_available?(@port)
|
|
45
46
|
# We own the port — start HTTP + MCP with shared in-memory store
|
|
46
47
|
http_thread = Thread.new do
|
|
47
|
-
http = HttpServer.new(store: @store, port: @port)
|
|
48
|
-
$stderr.puts "[rails-markup] HTTP server listening on
|
|
48
|
+
http = HttpServer.new(store: @store, port: @port, bind: @bind)
|
|
49
|
+
$stderr.puts "[rails-markup] HTTP server listening on #{@bind}:#{@port}"
|
|
49
50
|
http.start
|
|
50
51
|
end
|
|
51
52
|
|
|
@@ -63,7 +64,7 @@ module RailsMarkup
|
|
|
63
64
|
end
|
|
64
65
|
|
|
65
66
|
def port_available?(port)
|
|
66
|
-
server = TCPServer.new(
|
|
67
|
+
server = TCPServer.new(@bind, port)
|
|
67
68
|
server.close
|
|
68
69
|
true
|
|
69
70
|
rescue Errno::EADDRINUSE
|
data/lib/rails_markup/store.rb
CHANGED
|
@@ -214,6 +214,17 @@ module RailsMarkup
|
|
|
214
214
|
false
|
|
215
215
|
end
|
|
216
216
|
end
|
|
217
|
+
|
|
218
|
+
# Hard cap: if every session is still fresh we'd otherwise grow without
|
|
219
|
+
# bound. Evict the oldest sessions to make room for the incoming one.
|
|
220
|
+
return if @sessions.size < MAX_SESSIONS
|
|
221
|
+
|
|
222
|
+
overflow = @sessions.size - MAX_SESSIONS + 1
|
|
223
|
+
oldest = @sessions.values.sort_by(&:created_at).first(overflow)
|
|
224
|
+
oldest.each do |session|
|
|
225
|
+
session.annotations.each { |a| @annotations_index.delete(a.id) }
|
|
226
|
+
@sessions.delete(session.id)
|
|
227
|
+
end
|
|
217
228
|
end
|
|
218
229
|
end
|
|
219
230
|
end
|
data/lib/rails_markup/version.rb
CHANGED