Skip to content

Commit 014f959

Browse files
committed
feat: validate script import payloads
1 parent 469668d commit 014f959

5 files changed

Lines changed: 77 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ All notable changes to ScriptHunt will be documented in this file.
1717
- GitHub token settings in Diagnostics with save/remove controls, rate-limit checks, authenticated search headers, and redacted diagnostics.
1818
- Manager-aware security scan findings for broad `@match`/`@include`, enumerated `@connect`, pinned vs floating `@require`, and update/download URL host drift.
1919
- Metadata parsing now preserves localized `@name`/`@description`, `@exclude-match`, compatibility directives, and repeated keys without overwriting values.
20+
- Installed-script exports with `scripthunt-installed` schema v1 plus stricter import validation that skips bad rows without aborting valid rows.
2021

2122
### Fixed
2223
- Metadata scan cache keys now stay stable when a script declares a different `@updateURL`.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ npm run qa
5656
| **Permission Risk Pills** | Color-coded pills showing @grant danger levels (safe/warn/danger) per script |
5757
| **Script Comparison** | Select up to 3 scripts for side-by-side comparison with best-value highlighting |
5858
| **Favorites** | Save scripts to localStorage with versioned JSON export/import and undo on removal |
59-
| **Installed Import** | Import installed-script lists locally to mark installed scripts and available updates in search results |
59+
| **Installed Import/Export** | Import and export installed-script lists locally to mark installed scripts and available updates in search results |
6060
| **Advanced Query Syntax** | `site:`, `author:`, `updated:`, `grant:` operators with domain-aware by-site.json search and metadata-backed grant filtering |
6161
| **Advanced Filters** | Visible controls for source, license, installs, updated date, catalog language, @grant, risk, and applies-to domain |
6262
| **Applies-To Evidence** | Site-filtered results show source site matches alongside parsed `@match`, `@include`, and `@exclude` metadata evidence |
@@ -231,7 +231,7 @@ Yes. Deploy the included Cloudflare Worker template (free tier: 100K requests/da
231231
- **CORS Proxy** — allorigins.win → codetabs → everyorigin fallback chain with exponential backoff
232232
- **PWA** — manifest.json + service worker for installability, offline shell loading, and local recent-search recovery
233233
- **localStorage + IndexedDB** - preferences, favorites, source toggles, theme, recent search results, and scan cache persist locally; no tracking, no cookies, no server-side state
234-
- **Versioned JSON payloads** - favorites exports use `scripthunt-favorites` schema v1; installed imports accept `scripthunt-installed` schema v1, manager-style `scripts` arrays, and legacy arrays
234+
- **Versioned JSON payloads** - favorites and installed-list exports use schema v1; imports validate URLs, report skipped invalid rows, and still accept manager-style `scripts` arrays plus legacy arrays
235235
- **Local QA** - npm run qa runs npm audit, Worker tests, and Playwright smoke/adapter tests against the repo-local static server
236236

237237
---

ROADMAP.md

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,6 @@
2727
## Research-Driven Additions
2828

2929
### P2
30-
- [ ] P2 - Version and validate all import/export payloads
31-
Why: Favorites import currently accepts arbitrary arrays and can persist malformed records or inconsistent URLs.
32-
Evidence: `index.html:637`, `index.html:647`, `index.html:2237`, quoid/userscripts installURL issue, ScriptCat migration/backup issues.
33-
Touches: `index.html` favorites export/import, installed-script import model, README import/export docs, Playwright import tests.
34-
Acceptance: favorites and installed-script exports include schema/version fields, imports validate and normalize records before persistence, invalid rows are reported without aborting valid rows, and legacy array exports still import through a migration path.
35-
Complexity: M
36-
3730
- [ ] P2 - Add PWA update and cache recovery controls
3831
Why: The service worker uses cache-first shell behavior without telling users when a new shell is available or when cache recovery was used.
3932
Evidence: `sw.js`, README PWA feature, MDN StorageManager/offline guidance.

index.html

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,7 @@
382382
<button class="tool-btn" id="btnBookmarklet" title="Bookmarklet tool" popovertarget="bookmarkletSection">Bookmarklet</button>
383383
<button class="tool-btn" id="btnFavorites" title="View favorites">Favorites</button>
384384
<button class="tool-btn" id="btnImportInstalled" title="Import installed scripts">Installed</button>
385+
<button class="tool-btn" id="btnExportInstalled" title="Export installed scripts">Export Installed</button>
385386
<button class="tool-btn" id="btnDiagnostics" title="Source diagnostics" popovertarget="diagnosticsSection">Diagnostics</button>
386387
<button class="tool-btn" id="btnExportFavs" title="Export favorites" style="display:none">Export</button>
387388
<button class="tool-btn" id="btnImportFavs" title="Import favorites" style="display:none">Import</button>
@@ -835,6 +836,13 @@ <h3>Search userscripts everywhere</h3>
835836
function isFav(id) { return getFavs().some(function(f) { return f.id === id; }); }
836837

837838
function cleanUrl(u) { return (u || '').trim(); }
839+
function validImportUrl(u) {
840+
if (!u) return true;
841+
try {
842+
var url = new URL(String(u).trim());
843+
return url.protocol === 'http:' || url.protocol === 'https:';
844+
} catch(e) { return false; }
845+
}
838846
function normalizeScriptUrls(item) {
839847
item.installUrl = cleanUrl(item.installUrl || item.downloadUrl || item.updateUrl || '');
840848
item.downloadUrl = cleanUrl(item.downloadUrl || item.installUrl || '');
@@ -863,6 +871,8 @@ <h3>Search userscripts everywhere</h3>
863871

864872
function normalizeImportedScript(item) {
865873
if (!item || typeof item !== 'object') return null;
874+
var rawUrls = [item.url, item.homepageURL, item.homepage, item.installUrl, item.install_url, item.downloadURL, item.downloadUrl, item.download_url, item.updateUrl, item.updateURL, item.update_url];
875+
if (rawUrls.some(function(u) { return u && !validImportUrl(u); })) return null;
866876
var out = exportScriptRecord({
867877
id: item.id || item.uuid || '',
868878
name: item.name || item.title || '',
@@ -944,14 +954,25 @@ <h3>Search userscripts everywhere</h3>
944954
};
945955
}
946956

957+
function buildInstalledExport() {
958+
return {
959+
schema: 'scripthunt-installed',
960+
version: 1,
961+
exportedAt: new Date().toISOString(),
962+
installed: getInstalledScripts().map(normalizeImportedScript).filter(Boolean),
963+
};
964+
}
965+
947966
function parseScriptImportPayload(payload, preferredType) {
948967
var type = preferredType || 'favorites';
949968
var rows = [];
950969
if (Array.isArray(payload)) {
951970
rows = payload;
952971
} else if (payload && typeof payload === 'object') {
972+
if (payload.schema && payload.version !== 1) throw new Error('Unsupported import version');
953973
if (payload.schema === 'scripthunt-installed') { type = 'installed'; rows = payload.installed || payload.scripts || []; }
954974
else if (payload.schema === 'scripthunt-favorites') { type = 'favorites'; rows = payload.favorites || []; }
975+
else if (payload.schema) throw new Error('Unsupported import schema');
955976
else if (Array.isArray(payload.installed) || Array.isArray(payload.scripts)) { type = 'installed'; rows = payload.installed || payload.scripts; }
956977
else if (Array.isArray(payload.favorites)) { type = 'favorites'; rows = payload.favorites; }
957978
}
@@ -2937,20 +2958,29 @@ <h3>Search userscripts everywhere</h3>
29372958
});
29382959

29392960
// Favorites button
2940-
var btnExport = document.getElementById('btnExportFavs'), btnImport = document.getElementById('btnImportFavs'), btnImportInstalled = document.getElementById('btnImportInstalled');
2961+
var btnExport = document.getElementById('btnExportFavs'), btnImport = document.getElementById('btnImportFavs'), btnImportInstalled = document.getElementById('btnImportInstalled'), btnExportInstalled = document.getElementById('btnExportInstalled');
29412962
document.getElementById('btnFavorites').addEventListener('click', function() {
29422963
this.classList.toggle('active');
29432964
var active = this.classList.contains('active');
29442965
btnExport.style.display = active ? '' : 'none'; btnImport.style.display = active ? '' : 'none';
29452966
if (active) { showFavorites(); } else { if (state.rawQuery) { state.showFavs = false; sortAndRender(); } else { clearBtn.click(); } }
29462967
});
29472968

2969+
function downloadJson(payload, filename) {
2970+
var blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
2971+
var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; a.click();
2972+
URL.revokeObjectURL(a.href);
2973+
}
2974+
29482975
// Export favorites
29492976
btnExport.addEventListener('click', function() {
29502977
var favs = getFavs(); if (!favs.length) { showToast('No favorites to export', 'error', 2000); return; }
2951-
var blob = new Blob([JSON.stringify(buildFavoritesExport(), null, 2)], { type: 'application/json' });
2952-
var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'scripthunt-favorites.json'; a.click();
2953-
URL.revokeObjectURL(a.href); showToast('Exported ' + favs.length + ' favorites', 'info', 2000);
2978+
downloadJson(buildFavoritesExport(), 'scripthunt-favorites.json'); showToast('Exported ' + favs.length + ' favorites', 'info', 2000);
2979+
});
2980+
2981+
btnExportInstalled.addEventListener('click', function() {
2982+
var installed = getInstalledScripts(); if (!installed.length) { showToast('No installed scripts to export', 'error', 2000); return; }
2983+
downloadJson(buildInstalledExport(), 'scripthunt-installed.json'); showToast('Exported ' + installed.length + ' installed scripts', 'info', 2000);
29542984
});
29552985

29562986
// Import favorites

tests/smoke.spec.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,46 @@ test('installed import marks matching scripts and update state', async ({ page }
280280
const card = page.locator('.result-card').filter({ hasText: 'YouTube Enhancer' });
281281
await expect(card).toContainText('Update available');
282282
await expect(card.locator('.card-btn-install')).toContainText('Update');
283+
284+
const downloadPromise = page.waitForEvent('download');
285+
await page.click('#btnExportInstalled');
286+
const download = await downloadPromise;
287+
const text = await fs.readFile(await download.path(), 'utf8');
288+
const payload = JSON.parse(text);
289+
expect(payload.schema).toBe('scripthunt-installed');
290+
expect(payload.version).toBe(1);
291+
expect(payload.installed[0]).toMatchObject({
292+
name: 'YouTube Enhancer',
293+
version: '0.9.0',
294+
installUrl: 'https://greasyfork.org/scripts/101-youtube-enhancer/code/YouTube%20Enhancer.user.js',
295+
});
296+
});
297+
298+
test('script imports skip invalid rows without aborting valid rows', async ({ page }) => {
299+
await page.goto('/');
300+
const chooserPromise = page.waitForEvent('filechooser');
301+
await page.click('#btnImportInstalled');
302+
const chooser = await chooserPromise;
303+
await chooser.setFiles({
304+
name: 'installed-mixed.json',
305+
mimeType: 'application/json',
306+
buffer: Buffer.from(JSON.stringify({
307+
schema: 'scripthunt-installed',
308+
version: 1,
309+
installed: [
310+
{ name: 'YouTube Enhancer', version: '1.0.0', installUrl: 'https://greasyfork.org/scripts/101-youtube-enhancer/code/YouTube%20Enhancer.user.js' },
311+
{ name: 'Bad URL', installUrl: 'javascript:alert(1)' },
312+
{ description: 'missing identity' },
313+
],
314+
})),
315+
});
316+
317+
await expect(page.locator('.toast').last()).toContainText('Imported 1 installed scripts (2 invalid skipped)');
318+
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('sh_installed_scripts')));
319+
expect(stored.schema).toBe('scripthunt-installed');
320+
expect(stored.version).toBe(1);
321+
expect(stored.installed).toHaveLength(1);
322+
expect(JSON.stringify(stored)).not.toContain('javascript:');
283323
});
284324

285325
test('result icon buttons have accessible names', async ({ page }) => {

0 commit comments

Comments
 (0)