context_drop 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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +21 -0
- data/README.md +337 -0
- data/Rakefile +20 -0
- data/SPEC.md +532 -0
- data/app/assets/javascripts/context_drop/context_drop.js +3508 -0
- data/app/assets/stylesheets/context_drop/application.css +2 -0
- data/app/controllers/context_drop/application_controller.rb +114 -0
- data/app/controllers/context_drop/attachments_controller.rb +31 -0
- data/app/controllers/context_drop/configs_controller.rb +20 -0
- data/app/controllers/context_drop/dashboard/feedbacks_controller.rb +107 -0
- data/app/controllers/context_drop/feedbacks_controller.rb +208 -0
- data/app/controllers/context_drop/health_controller.rb +9 -0
- data/app/controllers/context_drop/mcp_controller.rb +67 -0
- data/app/helpers/context_drop/widget_helper.rb +62 -0
- data/app/javascript/context_drop/__tests__/widget.test.ts +319 -0
- data/app/javascript/context_drop/api-client.ts +87 -0
- data/app/javascript/context_drop/element-picker.ts +1004 -0
- data/app/javascript/context_drop/icons.ts +35 -0
- data/app/javascript/context_drop/index.ts +11 -0
- data/app/javascript/context_drop/sanitizer.ts +454 -0
- data/app/javascript/context_drop/screenshot.ts +214 -0
- data/app/javascript/context_drop/styles.ts +690 -0
- data/app/javascript/context_drop/types.ts +110 -0
- data/app/javascript/context_drop/widget.ts +1120 -0
- data/app/models/context_drop/application_record.rb +7 -0
- data/app/models/context_drop/attachment.rb +30 -0
- data/app/models/context_drop/element_reference.rb +47 -0
- data/app/models/context_drop/feedback.rb +94 -0
- data/app/services/context_drop/metadata_sanitizer.rb +215 -0
- data/app/services/context_drop/upload_validator.rb +217 -0
- data/app/views/context_drop/dashboard/feedbacks/index.html.erb +86 -0
- data/app/views/context_drop/dashboard/feedbacks/show.html.erb +121 -0
- data/app/views/layouts/context_drop/application.html.erb +215 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20261109000001_create_context_drop_feedbacks.rb +23 -0
- data/db/migrate/20261109000002_create_context_drop_element_references.rb +29 -0
- data/db/migrate/20261109000003_create_context_drop_attachments.rb +24 -0
- data/lib/context_drop/base64_util.rb +15 -0
- data/lib/context_drop/configuration.rb +155 -0
- data/lib/context_drop/engine.rb +32 -0
- data/lib/context_drop/mcp/dispatcher.rb +197 -0
- data/lib/context_drop/mcp/prompts/investigate_feedback.rb +83 -0
- data/lib/context_drop/mcp/server.rb +25 -0
- data/lib/context_drop/mcp/stdio_runner.rb +62 -0
- data/lib/context_drop/mcp/tools/get_feedback.rb +133 -0
- data/lib/context_drop/mcp/tools/get_feedback_attachment.rb +71 -0
- data/lib/context_drop/mcp/tools/list_feedbacks.rb +127 -0
- data/lib/context_drop/mcp/tools/update_feedback_status.rb +72 -0
- data/lib/context_drop/version.rb +5 -0
- data/lib/context_drop.rb +32 -0
- data/lib/generators/context_drop/install/install_generator.rb +52 -0
- data/lib/generators/context_drop/install/templates/initializer.rb.tt +55 -0
- data/lib/tasks/context_drop_tasks.rake +14 -0
- metadata +246 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { BoundingRectSnapshot } from "./types.ts";
|
|
2
|
+
import { buildExcludedSelectorList, isElementExcluded } from "./sanitizer.ts";
|
|
3
|
+
|
|
4
|
+
export interface ScreenshotCaptureResult {
|
|
5
|
+
dataUrl: string;
|
|
6
|
+
blob: Blob;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
redactedCount: number;
|
|
10
|
+
metadata: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function collectRedactionRects(customExcludedSelectors: string[] = []): BoundingRectSnapshot[] {
|
|
14
|
+
if (typeof document === "undefined") return [];
|
|
15
|
+
|
|
16
|
+
const selectors = buildExcludedSelectorList(customExcludedSelectors);
|
|
17
|
+
const selectorQuery = [
|
|
18
|
+
...selectors.filter((s) => s !== "context-drop-widget" && s !== "[data-context-drop-ui]"),
|
|
19
|
+
"iframe"
|
|
20
|
+
].join(", ");
|
|
21
|
+
|
|
22
|
+
const rects: BoundingRectSnapshot[] = [];
|
|
23
|
+
let candidates: NodeListOf<Element>;
|
|
24
|
+
try {
|
|
25
|
+
candidates = document.querySelectorAll(selectorQuery);
|
|
26
|
+
} catch {
|
|
27
|
+
candidates = document.querySelectorAll(
|
|
28
|
+
"[data-feedback-ignore], [data-feedback-sensitive], input[type='password'], input[type='hidden'], iframe"
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (let i = 0; i < candidates.length; i += 1) {
|
|
33
|
+
const el = candidates[i];
|
|
34
|
+
if (el.closest("context-drop-widget")) continue;
|
|
35
|
+
|
|
36
|
+
// For iframes, redact cross-origin or excluded iframes
|
|
37
|
+
if (el.tagName.toLowerCase() === "iframe" && !isElementExcluded(el, customExcludedSelectors)) {
|
|
38
|
+
const src = el.getAttribute("src") || "";
|
|
39
|
+
const isCrossOrigin =
|
|
40
|
+
src.startsWith("http://") ||
|
|
41
|
+
src.startsWith("https://") ||
|
|
42
|
+
src.startsWith("//");
|
|
43
|
+
if (!isCrossOrigin) continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const r = el.getBoundingClientRect();
|
|
47
|
+
if (r.width > 0 && r.height > 0) {
|
|
48
|
+
rects.push({
|
|
49
|
+
x: r.x ?? r.left,
|
|
50
|
+
y: r.y ?? r.top,
|
|
51
|
+
width: r.width,
|
|
52
|
+
height: r.height
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return rects;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function dataUrlToBlob(dataUrl: string): Blob {
|
|
61
|
+
const parts = dataUrl.split(";base64,");
|
|
62
|
+
const mime = (parts[0] || "data:image/png").replace("data:", "");
|
|
63
|
+
const b64 = parts[1] || "";
|
|
64
|
+
const binary = typeof atob === "function" ? atob(b64) : "";
|
|
65
|
+
const bytes = new Uint8Array(binary.length);
|
|
66
|
+
for (let i = 0; i < binary.length; i += 1) {
|
|
67
|
+
bytes[i] = binary.charCodeAt(i);
|
|
68
|
+
}
|
|
69
|
+
return new Blob([bytes], { type: mime });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function captureViewportScreenshot(
|
|
73
|
+
customExcludedSelectors: string[] = [],
|
|
74
|
+
cropRegion?: BoundingRectSnapshot
|
|
75
|
+
): Promise<ScreenshotCaptureResult> {
|
|
76
|
+
if (typeof document === "undefined" || typeof window === "undefined") {
|
|
77
|
+
throw new Error("Screenshot capture is only available in a browser environment.");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const widgets = Array.from(document.querySelectorAll("context-drop-widget, [data-context-drop-ui]")) as HTMLElement[];
|
|
81
|
+
const previousVisibility = widgets.map((el) => el.style.visibility);
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
// 1. Hide ContextDrop's own UI during capture
|
|
85
|
+
for (const w of widgets) {
|
|
86
|
+
w.style.visibility = "hidden";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const vw = Math.max(window.innerWidth || 1024, 1);
|
|
90
|
+
const vh = Math.max(window.innerHeight || 768, 1);
|
|
91
|
+
const region: BoundingRectSnapshot = cropRegion
|
|
92
|
+
? {
|
|
93
|
+
x: Math.max(0, Math.min(cropRegion.x, vw - 1)),
|
|
94
|
+
y: Math.max(0, Math.min(cropRegion.y, vh - 1)),
|
|
95
|
+
width: Math.max(1, Math.min(cropRegion.width, vw - Math.max(0, cropRegion.x))),
|
|
96
|
+
height: Math.max(1, Math.min(cropRegion.height, vh - Math.max(0, cropRegion.y)))
|
|
97
|
+
}
|
|
98
|
+
: { x: 0, y: 0, width: vw, height: vh };
|
|
99
|
+
|
|
100
|
+
const redactionRects = collectRedactionRects(customExcludedSelectors);
|
|
101
|
+
|
|
102
|
+
const canvas = document.createElement("canvas");
|
|
103
|
+
canvas.width = Math.round(region.width);
|
|
104
|
+
canvas.height = Math.round(region.height);
|
|
105
|
+
const ctx = canvas.getContext("2d");
|
|
106
|
+
|
|
107
|
+
if (!ctx) {
|
|
108
|
+
throw new Error("Canvas 2D context is unavailable or restricted by browser policy.");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Paint page background
|
|
112
|
+
const bodyBg =
|
|
113
|
+
window.getComputedStyle && document.body
|
|
114
|
+
? window.getComputedStyle(document.body).backgroundColor
|
|
115
|
+
: "#ffffff";
|
|
116
|
+
ctx.fillStyle = bodyBg && bodyBg !== "rgba(0, 0, 0, 0)" ? bodyBg : "#ffffff";
|
|
117
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
118
|
+
|
|
119
|
+
// Paint visible non-excluded DOM elements approximation onto canvas
|
|
120
|
+
const visibleNodes = document.body ? Array.from(document.body.querySelectorAll("*")) : [];
|
|
121
|
+
for (const node of visibleNodes) {
|
|
122
|
+
if (node.closest("context-drop-widget") || node.hasAttribute("data-context-drop-ui")) continue;
|
|
123
|
+
if (isElementExcluded(node, customExcludedSelectors)) continue;
|
|
124
|
+
|
|
125
|
+
const r = node.getBoundingClientRect();
|
|
126
|
+
if (r.width <= 0 || r.height <= 0) continue;
|
|
127
|
+
|
|
128
|
+
const rx = (r.x ?? r.left) - region.x;
|
|
129
|
+
const ry = (r.y ?? r.top) - region.y;
|
|
130
|
+
if (rx + r.width < 0 || ry + r.height < 0 || rx > region.width || ry > region.height) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const style = window.getComputedStyle ? window.getComputedStyle(node) : null;
|
|
135
|
+
if (style) {
|
|
136
|
+
if (style.backgroundColor && style.backgroundColor !== "rgba(0, 0, 0, 0)" && style.backgroundColor !== "transparent") {
|
|
137
|
+
ctx.fillStyle = style.backgroundColor;
|
|
138
|
+
ctx.fillRect(rx, ry, r.width, r.height);
|
|
139
|
+
}
|
|
140
|
+
if (style.borderWidth && parseFloat(style.borderWidth) > 0) {
|
|
141
|
+
ctx.strokeStyle = style.borderColor || "#cbd5e1";
|
|
142
|
+
ctx.lineWidth = Math.min(parseFloat(style.borderWidth), 4);
|
|
143
|
+
ctx.strokeRect(rx, ry, r.width, r.height);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 2. Enforce pre-capture visual redaction of all sensitive/excluded elements
|
|
149
|
+
for (const red of redactionRects) {
|
|
150
|
+
const rx = red.x - region.x;
|
|
151
|
+
const ry = red.y - region.y;
|
|
152
|
+
if (rx + red.width < 0 || ry + red.height < 0 || rx > region.width || ry > region.height) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
ctx.fillStyle = "#0f172a";
|
|
156
|
+
ctx.fillRect(rx, ry, red.width, red.height);
|
|
157
|
+
ctx.fillStyle = "#94a3b8";
|
|
158
|
+
ctx.font = "11px sans-serif";
|
|
159
|
+
if (red.width >= 60 && red.height >= 16) {
|
|
160
|
+
ctx.fillText("[REDACTED]", rx + 6, ry + Math.min(red.height - 4, 14));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const dataUrl = canvas.toDataURL("image/png");
|
|
165
|
+
const blob = dataUrlToBlob(dataUrl);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
dataUrl,
|
|
169
|
+
blob,
|
|
170
|
+
width: canvas.width,
|
|
171
|
+
height: canvas.height,
|
|
172
|
+
redactedCount: redactionRects.length,
|
|
173
|
+
metadata: {
|
|
174
|
+
capture_type: cropRegion ? "element" : "viewport",
|
|
175
|
+
image_dimensions: { width: canvas.width, height: canvas.height },
|
|
176
|
+
viewport: { width: vw, height: vh },
|
|
177
|
+
redacted_elements_count: redactionRects.length,
|
|
178
|
+
captured_at: new Date().toISOString()
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
} finally {
|
|
182
|
+
// Always restore ContextDrop UI visibility
|
|
183
|
+
widgets.forEach((el, idx) => {
|
|
184
|
+
el.style.visibility = previousVisibility[idx] || "";
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function captureElementScreenshot(
|
|
190
|
+
element: Element,
|
|
191
|
+
padding: number = 16,
|
|
192
|
+
customExcludedSelectors: string[] = [],
|
|
193
|
+
elementClientRef?: string
|
|
194
|
+
): Promise<ScreenshotCaptureResult> {
|
|
195
|
+
if (isElementExcluded(element, customExcludedSelectors)) {
|
|
196
|
+
throw new Error("Cannot capture screenshot of an excluded or sensitive DOM element.");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const r = element.getBoundingClientRect();
|
|
200
|
+
const pad = Math.max(0, padding);
|
|
201
|
+
const cropRegion: BoundingRectSnapshot = {
|
|
202
|
+
x: (r.x ?? r.left) - pad,
|
|
203
|
+
y: (r.y ?? r.top) - pad,
|
|
204
|
+
width: r.width + pad * 2,
|
|
205
|
+
height: r.height + pad * 2
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const result = await captureViewportScreenshot(customExcludedSelectors, cropRegion);
|
|
209
|
+
result.metadata.capture_type = "element";
|
|
210
|
+
if (elementClientRef) {
|
|
211
|
+
result.metadata.element_attachment_ref = elementClientRef;
|
|
212
|
+
}
|
|
213
|
+
return result;
|
|
214
|
+
}
|