@ever-works/urlbox-plugin 1.0.0 → 1.0.3

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.
@@ -1,406 +0,0 @@
1
- import type {
2
- IPlugin,
3
- IScreenshotPlugin,
4
- PluginContext,
5
- PluginCategory,
6
- PluginManifest,
7
- PluginHealthCheck,
8
- JsonSchema,
9
- PluginSettings,
10
- ScreenshotOptions,
11
- ScreenshotResult,
12
- ScreenshotFormat,
13
- ScreenshotValidationResult,
14
- ConnectionValidationResult
15
- } from '@ever-works/plugin';
16
-
17
- import Urlbox, { type RenderOptions } from 'urlbox';
18
-
19
- // Security (SSRF): shared lexical guard rejecting non-HTTP(S) schemes and
20
- // literal private/loopback/link-local/cloud-metadata IPs. Imported directly
21
- // from the helper subpath because the plugin barrel intentionally does not
22
- // re-export it (it pulls in node:net / node:dns).
23
- import { isSafeWebhookUrl } from '@ever-works/plugin/helpers/ssrf-guard';
24
-
25
- interface UrlboxSettings {
26
- readonly apiKey?: string;
27
- readonly apiSecret?: string;
28
- readonly viewportWidth?: number;
29
- readonly viewportHeight?: number;
30
- readonly format?: ScreenshotFormat;
31
- readonly fullPage?: boolean;
32
- readonly quality?: number;
33
- readonly retina?: boolean;
34
- readonly blockAds?: boolean;
35
- readonly hideCookieBanners?: boolean;
36
- }
37
-
38
- export class UrlboxPlugin implements IPlugin, IScreenshotPlugin {
39
- readonly id = 'urlbox';
40
- readonly name = 'Urlbox';
41
- readonly version = '1.0.0';
42
- readonly category: PluginCategory = 'screenshot';
43
- readonly capabilities: readonly string[] = ['screenshot'];
44
- readonly providerName = 'Urlbox';
45
-
46
- readonly settingsSchema: JsonSchema = {
47
- type: 'object',
48
- properties: {
49
- apiKey: {
50
- type: 'string',
51
- title: 'API Key',
52
- description: 'Your Urlbox API key. Get one at https://urlbox.com',
53
- 'x-secret': true,
54
- 'x-envVar': 'PLUGIN_URLBOX_API_KEY',
55
- 'x-scope': 'user'
56
- },
57
- apiSecret: {
58
- type: 'string',
59
- title: 'API Secret',
60
- description: 'Your Urlbox API secret for signed render links (recommended for security)',
61
- 'x-secret': true,
62
- 'x-envVar': 'PLUGIN_URLBOX_API_SECRET',
63
- 'x-scope': 'user'
64
- },
65
- viewportWidth: {
66
- type: 'number',
67
- title: 'Viewport Width',
68
- description: 'Default viewport width in pixels',
69
- default: 1280,
70
- minimum: 320,
71
- maximum: 3840,
72
- 'x-envVar': 'PLUGIN_URLBOX_VIEWPORT_WIDTH'
73
- },
74
- viewportHeight: {
75
- type: 'number',
76
- title: 'Viewport Height',
77
- description: 'Default viewport height in pixels',
78
- default: 1024,
79
- minimum: 200,
80
- maximum: 2160,
81
- 'x-envVar': 'PLUGIN_URLBOX_VIEWPORT_HEIGHT'
82
- },
83
- format: {
84
- type: 'string',
85
- title: 'Image Format',
86
- description: 'Default output image format',
87
- enum: ['png', 'jpg', 'jpeg', 'webp'],
88
- default: 'png',
89
- 'x-envVar': 'PLUGIN_URLBOX_FORMAT'
90
- },
91
- fullPage: {
92
- type: 'boolean',
93
- title: 'Full Page',
94
- description: 'Capture full page by default',
95
- default: false
96
- },
97
- quality: {
98
- type: 'number',
99
- title: 'Image Quality',
100
- description: 'Image quality for lossy formats (1-100)',
101
- default: 80,
102
- minimum: 1,
103
- maximum: 100
104
- },
105
- retina: {
106
- type: 'boolean',
107
- title: 'Retina',
108
- description: 'Enable retina/HiDPI rendering (2x device scale factor)',
109
- default: false
110
- },
111
- blockAds: {
112
- type: 'boolean',
113
- title: 'Block Ads',
114
- description: 'Block ads when capturing screenshots',
115
- default: true
116
- },
117
- hideCookieBanners: {
118
- type: 'boolean',
119
- title: 'Hide Cookie Banners',
120
- description: 'Hide cookie consent banners when capturing screenshots',
121
- default: true
122
- }
123
- },
124
- required: ['apiKey']
125
- };
126
-
127
- readonly configurationMode: 'admin-only' | 'user-required' | 'hybrid' = 'hybrid';
128
-
129
- private context?: PluginContext;
130
-
131
- async capture(options: ScreenshotOptions): Promise<ScreenshotResult> {
132
- const settings = this.mergeSettings(options.settings);
133
-
134
- // Security (SSRF): `options.url` is attacker-controllable (work source
135
- // URLs, LLM-generated item URLs) and is forwarded to Urlbox, which
136
- // renders it in a real headless browser and returns a screenshot of the
137
- // response. Block non-HTTP(S) schemes and literal
138
- // private/loopback/link-local/cloud-metadata targets (e.g.
139
- // http://169.254.169.254/...) before generating the render link.
140
- // Legitimate https item URLs are unaffected.
141
- if (!isSafeWebhookUrl(options.url)) {
142
- return {
143
- success: false,
144
- error: `URL host is not safe to capture (SSRF guard blocked: ${options.url})`
145
- };
146
- }
147
-
148
- try {
149
- const client = this.createClient(settings);
150
- const renderOptions = this.buildOptions(options, settings);
151
- const renderUrl = client.generateRenderLink(renderOptions);
152
- const response = await fetch(renderUrl);
153
-
154
- if (!response.ok) {
155
- throw new Error(`Urlbox render failed with status ${response.status}`);
156
- }
157
-
158
- const arrayBuffer = await response.arrayBuffer();
159
- const buffer = Buffer.from(arrayBuffer);
160
-
161
- return {
162
- success: true,
163
- imageBuffer: buffer,
164
- imageBase64: buffer.toString('base64'),
165
- imageUrl: renderUrl,
166
- width: options.viewportWidth ?? settings.viewportWidth ?? 1280,
167
- height: options.viewportHeight ?? settings.viewportHeight ?? 1024,
168
- fileSize: buffer.length
169
- };
170
- } catch (error) {
171
- const errorMessage = error instanceof Error ? error.message : String(error);
172
- this.context?.logger.error(`Urlbox capture failed: ${errorMessage}`);
173
-
174
- return {
175
- success: false,
176
- error: errorMessage
177
- };
178
- }
179
- }
180
-
181
- async getScreenshotUrl(options: ScreenshotOptions): Promise<string | null> {
182
- const settings = this.mergeSettings(options.settings);
183
-
184
- // Security (SSRF): same guard as capture() — `options.url` is
185
- // attacker-controllable and ends up in the generated Urlbox render
186
- // link. Refuse non-HTTP(S) schemes and private/loopback/link-local/
187
- // cloud-metadata targets; return null (the existing failure shape).
188
- if (!isSafeWebhookUrl(options.url)) {
189
- this.context?.logger.error(`Urlbox URL generation blocked by SSRF guard for unsafe URL: ${options.url}`);
190
- return null;
191
- }
192
-
193
- try {
194
- const client = this.createClient(settings);
195
- const renderOptions = this.buildOptions(options, settings);
196
-
197
- return client.generateRenderLink(renderOptions);
198
- } catch (error) {
199
- this.context?.logger.error(
200
- `Urlbox URL generation failed: ${error instanceof Error ? error.message : String(error)}`
201
- );
202
- return null;
203
- }
204
- }
205
-
206
- async isAvailable(): Promise<boolean> {
207
- if (!this.context) return false;
208
- const settings = await this.context.getSettings();
209
- return Boolean(settings?.apiKey);
210
- }
211
-
212
- async validateConnection(settings: Record<string, unknown>): Promise<ConnectionValidationResult> {
213
- const apiKey = settings.apiKey as string | undefined;
214
- if (!apiKey) {
215
- return { success: false, message: 'Urlbox API key is not configured.' };
216
- }
217
-
218
- try {
219
- const resolvedSettings = this.mergeSettings(settings);
220
- const client = this.createClient(resolvedSettings);
221
- const url = client.generateRenderLink({ url: 'https://example.com' });
222
-
223
- if (url && url.includes('api.urlbox.com')) {
224
- return { success: true, message: 'Urlbox connection verified.' };
225
- }
226
-
227
- return { success: false, message: 'Urlbox credentials appear invalid.' };
228
- } catch (error) {
229
- return {
230
- success: false,
231
- message: `Urlbox connection failed: ${error instanceof Error ? error.message : String(error)}`
232
- };
233
- }
234
- }
235
-
236
- async validateCredentials(): Promise<ScreenshotValidationResult> {
237
- if (!this.context) {
238
- return {
239
- valid: false,
240
- message: 'Plugin not initialized'
241
- };
242
- }
243
-
244
- try {
245
- const settings = await this.context.getSettings();
246
- const resolvedSettings = this.mergeSettings(settings);
247
-
248
- if (!resolvedSettings.apiKey) {
249
- return {
250
- valid: false,
251
- message: 'API key is not configured'
252
- };
253
- }
254
-
255
- const client = this.createClient(resolvedSettings);
256
- const url = client.generateRenderLink({ url: 'https://example.com' });
257
-
258
- if (url && url.includes('api.urlbox.com')) {
259
- return {
260
- valid: true,
261
- message: 'Credentials are valid'
262
- };
263
- }
264
-
265
- return {
266
- valid: false,
267
- message: 'Invalid credentials format'
268
- };
269
- } catch (error) {
270
- return {
271
- valid: false,
272
- message: error instanceof Error ? error.message : 'Unknown error'
273
- };
274
- }
275
- }
276
-
277
- getSupportedFormats(): readonly ScreenshotFormat[] {
278
- return ['png', 'jpg', 'jpeg', 'webp'] as const;
279
- }
280
-
281
- getMaxDimensions(): { width: number; height: number } {
282
- return { width: 3840, height: 2160 };
283
- }
284
-
285
- private createClient(settings: UrlboxSettings) {
286
- const apiKey = settings.apiKey;
287
- const apiSecret = settings.apiSecret ?? '';
288
-
289
- if (!apiKey) {
290
- throw new Error(
291
- 'Urlbox API key not configured. ' +
292
- 'Set it in plugin settings or via PLUGIN_URLBOX_API_KEY environment variable.'
293
- );
294
- }
295
-
296
- return Urlbox(apiKey, apiSecret);
297
- }
298
-
299
- private buildOptions(options: ScreenshotOptions, settings: UrlboxSettings): RenderOptions {
300
- const format = options.format ?? settings.format ?? 'png';
301
-
302
- const renderOptions: RenderOptions = {
303
- url: options.url,
304
- width: options.viewportWidth ?? settings.viewportWidth ?? 1280,
305
- height: options.viewportHeight ?? settings.viewportHeight ?? 1024,
306
- format: format as RenderOptions['format'],
307
- full_page: options.fullPage ?? settings.fullPage ?? false,
308
- quality: settings.quality ?? 80,
309
- retina: settings.retina ?? false,
310
- block_ads: options.blockAds ?? settings.blockAds ?? true,
311
- hide_cookie_banners: options.blockCookieBanners ?? settings.hideCookieBanners ?? true
312
- };
313
-
314
- if (options.delay !== undefined) {
315
- renderOptions.delay = options.delay;
316
- }
317
-
318
- if (options.waitForSelector) {
319
- renderOptions.selector = options.waitForSelector;
320
- }
321
-
322
- if (options.userAgent) {
323
- renderOptions.user_agent = options.userAgent;
324
- }
325
-
326
- return renderOptions;
327
- }
328
-
329
- private mergeSettings(settings?: PluginSettings): UrlboxSettings {
330
- return {
331
- apiKey: settings?.apiKey as string | undefined,
332
- apiSecret: settings?.apiSecret as string | undefined,
333
- viewportWidth: (settings?.viewportWidth as number | undefined) ?? 1280,
334
- viewportHeight: (settings?.viewportHeight as number | undefined) ?? 1024,
335
- format: (settings?.format as ScreenshotFormat | undefined) ?? 'png',
336
- fullPage: (settings?.fullPage as boolean | undefined) ?? false,
337
- quality: (settings?.quality as number | undefined) ?? 80,
338
- retina: (settings?.retina as boolean | undefined) ?? false,
339
- blockAds: (settings?.blockAds as boolean | undefined) ?? true,
340
- hideCookieBanners: (settings?.hideCookieBanners as boolean | undefined) ?? true
341
- };
342
- }
343
-
344
- async onLoad(context: PluginContext): Promise<void> {
345
- this.context = context;
346
- context.logger.log('Urlbox Plugin loaded');
347
- }
348
-
349
- async onUnload(): Promise<void> {
350
- this.context = undefined;
351
- }
352
-
353
- async healthCheck(): Promise<PluginHealthCheck> {
354
- return {
355
- status: 'healthy',
356
- message: 'Urlbox plugin is ready (API key required for operations)',
357
- checkedAt: Date.now()
358
- };
359
- }
360
-
361
- getManifest(): PluginManifest {
362
- return {
363
- id: this.id,
364
- name: this.name,
365
- version: this.version,
366
- description: 'Automatically capture website screenshots for your work items',
367
- category: this.category,
368
- capabilities: [...this.capabilities],
369
- author: { name: 'Ever Works Team' },
370
- license: 'AGPL-3.0',
371
- builtIn: false,
372
- systemPlugin: false,
373
- readme: [
374
- '## What does Urlbox do?',
375
- '',
376
- 'Urlbox is a screenshot capture API. When work items include source URLs, this plugin automatically generates preview images by capturing a screenshot of each page.',
377
- '',
378
- '## Why use it?',
379
- '',
380
- '- **Automated capture** — preview images are generated for each work item without manual effort',
381
- '- **Consistent output** — every screenshot uses the same viewport size, format, and rendering settings',
382
- '- **Ad and cookie banner blocking** — captures clean screenshots free of ads and cookie banners',
383
- '- **Retina rendering** — supports HiDPI output for crisp images on high-resolution displays',
384
- '- **Multiple formats** — supports PNG, JPG, and WebP output with configurable quality',
385
- '',
386
- '## How it works in Ever Works',
387
- '',
388
- 'During work generation, the screenshot facade sends capture requests to Urlbox for items that have a source URL. The resulting images are used as item preview thumbnails. You can configure viewport dimensions, image format, quality, retina rendering, and ad/cookie blocking behavior.',
389
- '',
390
- '## Getting started',
391
- '',
392
- '1. Sign up at [urlbox.com](https://urlbox.com)',
393
- '2. Copy your API key and API secret',
394
- '3. Enable the Urlbox plugin on this page',
395
- '4. Enter your credentials in the settings below'
396
- ].join('\n'),
397
- homepage: 'https://urlbox.com',
398
- icon: {
399
- type: 'url',
400
- value: 'https://urlbox.com/apple-touch-icon.png'
401
- }
402
- };
403
- }
404
- }
405
-
406
- export default UrlboxPlugin;
package/tsconfig.json DELETED
@@ -1,23 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "module": "ESNext",
4
- "moduleResolution": "bundler",
5
- "target": "ES2021",
6
- "types": ["node"],
7
- "strict": true,
8
- "strictNullChecks": true,
9
- "noImplicitAny": true,
10
- "declaration": true,
11
- "declarationMap": true,
12
- "sourceMap": true,
13
- "outDir": "./dist",
14
- "rootDir": "./src",
15
- "esModuleInterop": true,
16
- "skipLibCheck": true,
17
- "forceConsistentCasingInFileNames": true,
18
- "isolatedModules": true,
19
- "noEmit": true
20
- },
21
- "include": ["src/**/*"],
22
- "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
23
- }
package/tsup.config.ts DELETED
@@ -1,12 +0,0 @@
1
- import { defineConfig } from 'tsup';
2
-
3
- export default defineConfig({
4
- entry: ['src/index.ts'],
5
- noExternal: ['@ever-works/plugin'],
6
- format: ['cjs', 'esm'],
7
- dts: true,
8
- clean: true,
9
- sourcemap: false,
10
- splitting: false,
11
- treeshake: true
12
- });
package/vitest.config.ts DELETED
@@ -1,13 +0,0 @@
1
- import { defineConfig } from 'vitest/config';
2
-
3
- export default defineConfig({
4
- test: {
5
- globals: true,
6
- environment: 'node',
7
- include: ['src/**/*.{test,spec}.ts'],
8
- coverage: {
9
- reporter: ['text', 'json', 'html'],
10
- exclude: ['node_modules/', 'dist/']
11
- }
12
- }
13
- });