svbridge 1.0.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.
- package/bin/storagevisual-bridge +2 -0
- package/package.json +17 -0
- package/server.js +265 -0
- package/storagevisual-bridge +2 -0
- package/svbridge +2 -0
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "svbridge",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "StorageVisual local file bridge — gives the web app full disk access",
|
|
5
|
+
"main": "server.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"svbridge": "svbridge"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node server.js"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["storagevisual", "bridge", "disk", "storage"],
|
|
16
|
+
"license": "MIT"
|
|
17
|
+
}
|
package/server.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* StorageVisual Bridge — local file system server
|
|
4
|
+
* Runs at http://localhost:57321
|
|
5
|
+
* Gives the StorageVisual web app full file system access without browser restrictions.
|
|
6
|
+
*
|
|
7
|
+
* Security: CORS restricted to storagevisual.com only.
|
|
8
|
+
* The browser enforces this — no other website can call this server.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const http = require('http')
|
|
12
|
+
const fs = require('fs')
|
|
13
|
+
const fsp = require('fs/promises')
|
|
14
|
+
const path = require('path')
|
|
15
|
+
const { exec } = require('child_process')
|
|
16
|
+
const os = require('os')
|
|
17
|
+
|
|
18
|
+
const PORT = 57321
|
|
19
|
+
const VERSION = '1.0.0'
|
|
20
|
+
const ALLOWED_ORIGINS = [
|
|
21
|
+
'https://storagevisual.com',
|
|
22
|
+
'https://www.storagevisual.com',
|
|
23
|
+
'http://localhost:5173',
|
|
24
|
+
'http://localhost:4173',
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
// ── CORS headers ─────────────────────────────────────────────────────────────
|
|
28
|
+
function corsHeaders(origin) {
|
|
29
|
+
const allowed = ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0]
|
|
30
|
+
return {
|
|
31
|
+
'Access-Control-Allow-Origin': allowed,
|
|
32
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
33
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
34
|
+
'Access-Control-Max-Age': '86400',
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── JSON response helper ──────────────────────────────────────────────────────
|
|
39
|
+
function send(res, origin, status, data) {
|
|
40
|
+
const body = JSON.stringify(data)
|
|
41
|
+
res.writeHead(status, {
|
|
42
|
+
'Content-Type': 'application/json',
|
|
43
|
+
'Content-Length': Buffer.byteLength(body),
|
|
44
|
+
...corsHeaders(origin),
|
|
45
|
+
})
|
|
46
|
+
res.end(body)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Parse JSON body ───────────────────────────────────────────────────────────
|
|
50
|
+
function readBody(req) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
let data = ''
|
|
53
|
+
req.on('data', chunk => { data += chunk })
|
|
54
|
+
req.on('end', () => {
|
|
55
|
+
try { resolve(data ? JSON.parse(data) : {}) }
|
|
56
|
+
catch (e) { reject(e) }
|
|
57
|
+
})
|
|
58
|
+
req.on('error', reject)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Recursive directory scan ──────────────────────────────────────────────────
|
|
63
|
+
async function scanDir(dirPath, rootName, results, limit = 100000) {
|
|
64
|
+
if (results.length >= limit) return
|
|
65
|
+
let entries
|
|
66
|
+
try { entries = await fsp.readdir(dirPath, { withFileTypes: true }) }
|
|
67
|
+
catch { return } // permission denied — skip silently
|
|
68
|
+
|
|
69
|
+
await Promise.all(entries.map(async (entry) => {
|
|
70
|
+
if (results.length >= limit) return
|
|
71
|
+
const fullPath = path.join(dirPath, entry.name)
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
await scanDir(fullPath, rootName, results, limit)
|
|
74
|
+
} else if (entry.isFile()) {
|
|
75
|
+
try {
|
|
76
|
+
const stat = await fsp.stat(fullPath)
|
|
77
|
+
// Store path relative to root folder name (matches web app format)
|
|
78
|
+
const rel = path.relative(path.dirname(dirPath), fullPath)
|
|
79
|
+
results.push({
|
|
80
|
+
name: entry.name,
|
|
81
|
+
path: rel.split(path.sep).join('/'),
|
|
82
|
+
size_bytes: stat.size,
|
|
83
|
+
lastModified: stat.mtimeMs,
|
|
84
|
+
})
|
|
85
|
+
} catch { /* skip */ }
|
|
86
|
+
}
|
|
87
|
+
}))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Native folder picker ──────────────────────────────────────────────────────
|
|
91
|
+
function pickFolder() {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
if (process.platform === 'darwin') {
|
|
94
|
+
exec(`osascript -e 'POSIX path of (choose folder)'`, (err, stdout) => {
|
|
95
|
+
if (err) reject(err)
|
|
96
|
+
else resolve(stdout.trim())
|
|
97
|
+
})
|
|
98
|
+
} else if (process.platform === 'win32') {
|
|
99
|
+
const ps = `Add-Type -AssemblyName System.Windows.Forms; $f=New-Object System.Windows.Forms.FolderBrowserDialog; if($f.ShowDialog() -eq 'OK'){$f.SelectedPath}`
|
|
100
|
+
exec(`powershell -Command "${ps}"`, (err, stdout) => {
|
|
101
|
+
if (err) reject(err)
|
|
102
|
+
else resolve(stdout.trim())
|
|
103
|
+
})
|
|
104
|
+
} else {
|
|
105
|
+
exec(`zenity --file-selection --directory 2>/dev/null || kdialog --getexistingdirectory 2>/dev/null`, (err, stdout) => {
|
|
106
|
+
if (err) reject(err)
|
|
107
|
+
else resolve(stdout.trim())
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Open file in native app ───────────────────────────────────────────────────
|
|
114
|
+
function openNative(filePath) {
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
const cmd = process.platform === 'win32'
|
|
117
|
+
? `start "" "${filePath}"`
|
|
118
|
+
: process.platform === 'darwin'
|
|
119
|
+
? `open "${filePath}"`
|
|
120
|
+
: `xdg-open "${filePath}"`
|
|
121
|
+
exec(cmd, (err) => err ? reject(err) : resolve())
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Auto-start on login ───────────────────────────────────────────────────────
|
|
126
|
+
function installAutoStart() {
|
|
127
|
+
const exe = process.execPath
|
|
128
|
+
if (process.platform === 'darwin') {
|
|
129
|
+
const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', 'com.storagevisual.bridge.plist')
|
|
130
|
+
if (!fs.existsSync(plistPath)) {
|
|
131
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
132
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
133
|
+
<plist version="1.0">
|
|
134
|
+
<dict>
|
|
135
|
+
<key>Label</key><string>com.storagevisual.bridge</string>
|
|
136
|
+
<key>ProgramArguments</key><array><string>${exe}</string></array>
|
|
137
|
+
<key>RunAtLoad</key><true/>
|
|
138
|
+
<key>KeepAlive</key><true/>
|
|
139
|
+
<key>StandardOutPath</key><string>${os.homedir()}/.storagevisual/bridge.log</string>
|
|
140
|
+
<key>StandardErrorPath</key><string>${os.homedir()}/.storagevisual/bridge.log</string>
|
|
141
|
+
</dict>
|
|
142
|
+
</plist>`
|
|
143
|
+
try {
|
|
144
|
+
fs.mkdirSync(path.dirname(plistPath), { recursive: true })
|
|
145
|
+
fs.writeFileSync(plistPath, plist)
|
|
146
|
+
exec(`launchctl load "${plistPath}"`)
|
|
147
|
+
console.log('✅ Auto-start installed (LaunchAgent)')
|
|
148
|
+
} catch (e) {
|
|
149
|
+
console.log('⚠️ Could not install auto-start:', e.message)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} else if (process.platform === 'win32') {
|
|
153
|
+
const { execSync } = require('child_process')
|
|
154
|
+
try {
|
|
155
|
+
execSync(`reg add "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run" /v StorageVisualBridge /t REG_SZ /d "${exe}" /f`)
|
|
156
|
+
console.log('✅ Auto-start installed (Registry)')
|
|
157
|
+
} catch (e) {
|
|
158
|
+
console.log('⚠️ Could not install auto-start:', e.message)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Request router ────────────────────────────────────────────────────────────
|
|
164
|
+
const server = http.createServer(async (req, res) => {
|
|
165
|
+
const origin = req.headers.origin || ''
|
|
166
|
+
const url = new URL(req.url, `http://localhost:${PORT}`)
|
|
167
|
+
|
|
168
|
+
// Preflight
|
|
169
|
+
if (req.method === 'OPTIONS') {
|
|
170
|
+
res.writeHead(204, corsHeaders(origin))
|
|
171
|
+
res.end()
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Only allow storagevisual.com origins (browser enforces CORS, this is belt-and-suspenders)
|
|
176
|
+
if (origin && !ALLOWED_ORIGINS.includes(origin)) {
|
|
177
|
+
send(res, origin, 403, { error: 'Forbidden' })
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
// GET /ping — health check
|
|
183
|
+
if (req.method === 'GET' && url.pathname === '/ping') {
|
|
184
|
+
send(res, origin, 200, { ok: true, version: VERSION, platform: process.platform })
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// GET /pick — open native folder picker, returns { path }
|
|
189
|
+
if (req.method === 'GET' && url.pathname === '/pick') {
|
|
190
|
+
const folderPath = await pickFolder()
|
|
191
|
+
if (!folderPath) { send(res, origin, 204, { path: null }); return }
|
|
192
|
+
send(res, origin, 200, { path: folderPath })
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// GET /scan?path=/Users/john/Downloads
|
|
197
|
+
if (req.method === 'GET' && url.pathname === '/scan') {
|
|
198
|
+
const scanPath = url.searchParams.get('path')
|
|
199
|
+
if (!scanPath) { send(res, origin, 400, { error: 'Missing path' }); return }
|
|
200
|
+
const resolved = path.resolve(scanPath)
|
|
201
|
+
const files = []
|
|
202
|
+
await scanDir(resolved, path.basename(resolved), files)
|
|
203
|
+
send(res, origin, 200, { files })
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// POST /open — open file in native app
|
|
208
|
+
if (req.method === 'POST' && url.pathname === '/open') {
|
|
209
|
+
const { path: filePath } = await readBody(req)
|
|
210
|
+
if (!filePath) { send(res, origin, 400, { error: 'Missing path' }); return }
|
|
211
|
+
await openNative(path.resolve(filePath))
|
|
212
|
+
send(res, origin, 200, { ok: true })
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// POST /delete — delete a file
|
|
217
|
+
if (req.method === 'POST' && url.pathname === '/delete') {
|
|
218
|
+
const { path: filePath } = await readBody(req)
|
|
219
|
+
if (!filePath) { send(res, origin, 400, { error: 'Missing path' }); return }
|
|
220
|
+
await fsp.unlink(path.resolve(filePath))
|
|
221
|
+
send(res, origin, 200, { ok: true })
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// POST /move — move a file to a new directory
|
|
226
|
+
if (req.method === 'POST' && url.pathname === '/move') {
|
|
227
|
+
const { from, toDir } = await readBody(req)
|
|
228
|
+
if (!from || !toDir) { send(res, origin, 400, { error: 'Missing from or toDir' }); return }
|
|
229
|
+
const src = path.resolve(from)
|
|
230
|
+
const dest = path.join(path.resolve(toDir), path.basename(src))
|
|
231
|
+
await fsp.rename(src, dest).catch(async () => {
|
|
232
|
+
// rename fails across volumes — copy + delete
|
|
233
|
+
await fsp.copyFile(src, dest)
|
|
234
|
+
await fsp.unlink(src)
|
|
235
|
+
})
|
|
236
|
+
send(res, origin, 200, { ok: true, newPath: dest })
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
send(res, origin, 404, { error: 'Not found' })
|
|
241
|
+
} catch (err) {
|
|
242
|
+
send(res, origin, 500, { error: err.message })
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
247
|
+
fs.mkdirSync(path.join(os.homedir(), '.storagevisual'), { recursive: true })
|
|
248
|
+
|
|
249
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
250
|
+
console.log(`\n🗂 StorageVisual Bridge v${VERSION}`)
|
|
251
|
+
console.log(` Listening on http://localhost:${PORT}`)
|
|
252
|
+
console.log(` Platform: ${process.platform} ${os.arch()}`)
|
|
253
|
+
console.log(` Only accepts requests from storagevisual.com\n`)
|
|
254
|
+
installAutoStart()
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
server.on('error', (err) => {
|
|
258
|
+
if (err.code === 'EADDRINUSE') {
|
|
259
|
+
console.log(`⚠️ Port ${PORT} already in use — bridge may already be running.`)
|
|
260
|
+
process.exit(0)
|
|
261
|
+
} else {
|
|
262
|
+
console.error('Bridge error:', err)
|
|
263
|
+
process.exit(1)
|
|
264
|
+
}
|
|
265
|
+
})
|
package/svbridge
ADDED