peertube-plugin-static-review 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.
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # peertube-plugin-static-review
2
+
3
+ A lightweight admin dev tools plugin for [PeerTube](https://joinpeertube.org/) that provides system monitoring, file browsing, and an optional command terminal — all from within the PeerTube admin interface.
4
+
5
+ ## Features
6
+
7
+ - **Logs Viewer** — In-memory log viewer with filtering by type (system, video, user) and one-click clear
8
+ - **System Info** — Real-time server stats including CPU, memory, uptime, load average, and Node.js version
9
+ - **Environment Variables** — View PeerTube-related environment variables (secrets are masked)
10
+ - **File Browser** — Browse the server filesystem with support for absolute paths, file preview, and file downloads
11
+ - **Terminal** — Execute shell commands on the server as the PeerTube user (disabled by default, must be explicitly enabled)
12
+
13
+ ## Screenshots
14
+
15
+ Access everything from a single tabbed interface via the **Dev Tools** sidebar link.
16
+
17
+ ## Requirements
18
+
19
+ - PeerTube **v4.0.0** or later
20
+ - Admin-level access
21
+
22
+ ## Installation
23
+
24
+ ### From npm
25
+
26
+ ```bash
27
+ cd /var/www/peertube/peertube-latest
28
+ sudo -u peertube NODE_CONFIG_DIR=/var/www/peertube/config NODE_ENV=production \
29
+ npm run plugin:install -- --npm-name peertube-plugin-static-review
30
+ sudo systemctl restart peertube
31
+ ```
32
+
33
+ Or install from the PeerTube admin UI under **Administration → Plugins/Themes → Search** and search for `static-review`.
34
+
35
+ ### From source
36
+
37
+ ```bash
38
+ git clone https://github.com/michaeljsjmartin/peertube-plugin-static-review.git /tmp/peertube-plugin-static-review
39
+ cd /var/www/peertube/peertube-latest
40
+ sudo -u peertube NODE_CONFIG_DIR=/var/www/peertube/config NODE_ENV=production \
41
+ npm run plugin:install -- --plugin-path /tmp/peertube-plugin-static-review
42
+ sudo systemctl restart peertube
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ After installation, click **🛠️ Dev Tools** in the left sidebar (you must be logged in as an admin).
48
+
49
+ ### Settings
50
+
51
+ Go to **Administration → Plugins → static-review → Settings** to configure:
52
+
53
+ | Setting | Description | Default |
54
+ |---|---|---|
55
+ | **Enable Command Execution** | Enables the terminal tab. Only turn this on if you understand the risks. | `false` |
56
+ | **Command Timeout** | Max execution time for commands in seconds | `30` |
57
+ | **Allowed File Browser Paths** | Comma-separated list of allowed paths, or `*` for unrestricted | `*` |
58
+
59
+ ## Security
60
+
61
+ - All API endpoints require admin authentication
62
+ - Command execution is **disabled by default** and must be explicitly enabled in settings
63
+ - Environment variable output masks values containing `PASSWORD`, `SECRET`, or `KEY`
64
+ - File path traversal (`..`) is stripped from all requests
65
+ - Commands run as the PeerTube system user, not root (unless PeerTube itself runs as root, which is not recommended)
66
+
67
+ > ⚠️ **Warning**: Enabling command execution gives full shell access as the PeerTube user. Only enable this in trusted environments.
68
+
69
+ ## Uninstallation
70
+
71
+ ```bash
72
+ cd /var/www/peertube/peertube-latest
73
+ sudo -u peertube NODE_CONFIG_DIR=/var/www/peertube/config NODE_ENV=production \
74
+ npm run plugin:uninstall -- --npm-name peertube-plugin-static-review
75
+ sudo systemctl restart peertube
76
+ ```
77
+
78
+ ## Troubleshooting
79
+
80
+ | Symptom | Fix |
81
+ |---|---|
82
+ | Sidebar link missing | Ensure plugin is enabled in admin UI, restart PeerTube, hard-refresh browser (`Ctrl+Shift+R`) |
83
+ | 404 on client page | Bump version in `package.json`, reinstall, and restart to bust cache |
84
+ | 403 on API calls | Must be logged in as admin |
85
+ | "No settings" in admin | Uninstall, restart, reinstall, restart again |
86
+ | ENOENT errors | Check that the file path exists and is readable by the PeerTube user |
87
+
88
+ ## License
89
+
90
+ MIT
91
+
92
+ ## Author
93
+
94
+ [michaeljsjmartin](https://github.com/michaeljsjmartin)
@@ -0,0 +1,259 @@
1
+ async function register ({ registerClientRoute, registerHook, peertubeHelpers }) {
2
+
3
+ registerHook({
4
+ target: 'filter:left-menu.links.create.result',
5
+ handler: (items) => {
6
+ return [...items, {
7
+ key: 'static-review',
8
+ title: 'Dev Tools',
9
+ links: [{
10
+ label: '🛠️ Dev Tools',
11
+ icon: 'cog',
12
+ shortLabel: 'DevTools',
13
+ path: '/p/devtools'
14
+ }]
15
+ }]
16
+ }
17
+ })
18
+
19
+ registerClientRoute({
20
+ route: 'devtools',
21
+ onMount: ({ rootEl }) => {
22
+ const base = peertubeHelpers.getBaseRouterRoute()
23
+
24
+ async function api (endpoint, opts = {}) {
25
+ const auth = peertubeHelpers.getAuthHeader() || {}
26
+ const res = await fetch(`${base}/${endpoint}`, {
27
+ ...opts,
28
+ headers: { 'Content-Type': 'application/json', ...auth, ...(opts.headers || {}) }
29
+ })
30
+ if (!res.ok) {
31
+ const e = await res.json().catch(() => ({ error: res.statusText }))
32
+ throw new Error(e.error || res.statusText)
33
+ }
34
+ return res.json()
35
+ }
36
+
37
+ rootEl.innerHTML = `
38
+ <div style="padding:20px;font-family:sans-serif;max-width:1100px;margin:0 auto">
39
+ <h1>🛠️ Admin Dev Tools</h1>
40
+ <div id="tabs" style="display:flex;border-bottom:2px solid #ddd;margin-bottom:20px">
41
+ <button class="tb" data-t="logs" style="padding:10px 18px;border:none;background:none;cursor:pointer;color:#f1680d;border-bottom:3px solid #f1680d;font-size:14px">Logs</button>
42
+ <button class="tb" data-t="system" style="padding:10px 18px;border:none;background:none;cursor:pointer;color:#666;font-size:14px">System</button>
43
+ <button class="tb" data-t="env" style="padding:10px 18px;border:none;background:none;cursor:pointer;color:#666;font-size:14px">Env</button>
44
+ <button class="tb" data-t="files" style="padding:10px 18px;border:none;background:none;cursor:pointer;color:#666;font-size:14px">Files</button>
45
+ <button class="tb" data-t="terminal" style="padding:10px 18px;border:none;background:none;cursor:pointer;color:#666;font-size:14px">Terminal</button>
46
+ </div>
47
+
48
+ <div id="p-logs">
49
+ <div style="display:flex;gap:8px;margin-bottom:12px">
50
+ <button id="rl" style="padding:6px 14px;background:#f1680d;color:#fff;border:none;border-radius:4px;cursor:pointer">Refresh</button>
51
+ <button id="cl" style="padding:6px 14px;background:#888;color:#fff;border:none;border-radius:4px;cursor:pointer">Clear</button>
52
+ <select id="lf" style="padding:6px;border:1px solid #ddd;border-radius:4px">
53
+ <option value="">All</option>
54
+ <option value="system">System</option>
55
+ <option value="video">Video</option>
56
+ <option value="user">User</option>
57
+ </select>
58
+ </div>
59
+ <div id="lo" style="background:#fafafa;border:1px solid #ddd;border-radius:4px;padding:12px;max-height:500px;overflow-y:auto;font-family:monospace;font-size:12px">Loading...</div>
60
+ </div>
61
+
62
+ <div id="p-system" style="display:none">
63
+ <button id="rs" style="padding:6px 14px;background:#f1680d;color:#fff;border:none;border-radius:4px;cursor:pointer;margin-bottom:12px">Refresh</button>
64
+ <pre id="so" style="background:#fafafa;border:1px solid #ddd;border-radius:4px;padding:12px;max-height:500px;overflow-y:auto;font-size:12px">Click Refresh</pre>
65
+ </div>
66
+
67
+ <div id="p-env" style="display:none">
68
+ <button id="re" style="padding:6px 14px;background:#f1680d;color:#fff;border:none;border-radius:4px;cursor:pointer;margin-bottom:12px">Refresh</button>
69
+ <pre id="eo" style="background:#fafafa;border:1px solid #ddd;border-radius:4px;padding:12px;max-height:500px;overflow-y:auto;font-size:12px">Click Refresh</pre>
70
+ </div>
71
+
72
+ <div id="p-files" style="display:none">
73
+ <div style="display:flex;gap:8px;margin-bottom:12px;align-items:center">
74
+ <span style="font-size:13px;color:#666">Path:</span>
75
+ <input id="fp" type="text" placeholder="e.g. /var/www/peertube" value=""
76
+ style="flex:1;padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-family:monospace;font-size:13px">
77
+ <button id="fg" style="padding:6px 14px;background:#f1680d;color:#fff;border:none;border-radius:4px;cursor:pointer">Go</button>
78
+ <button id="rf" style="padding:6px 14px;background:#888;color:#fff;border:none;border-radius:4px;cursor:pointer">Refresh</button>
79
+ </div>
80
+ <div style="display:flex;gap:8px;margin-bottom:12px;align-items:center">
81
+ <div id="bc" style="flex:1;font-size:13px"></div>
82
+ </div>
83
+ <div id="fo" style="background:#fafafa;border:1px solid #ddd;border-radius:4px;padding:12px;max-height:400px;overflow-y:auto;font-size:13px"></div>
84
+ <div id="fc" style="display:none;margin-top:12px">
85
+ <button id="xf" style="padding:6px 14px;background:#888;color:#fff;border:none;border-radius:4px;cursor:pointer;margin-bottom:8px">Close</button>
86
+ <pre id="ft" style="background:#fafafa;border:1px solid #ddd;border-radius:4px;padding:12px;max-height:400px;overflow-y:auto;font-size:12px"></pre>
87
+ </div>
88
+ </div>
89
+
90
+ <div id="p-terminal" style="display:none">
91
+ <div style="background:#fff3cd;border:2px solid #ffc107;border-radius:6px;padding:12px;margin-bottom:16px;text-align:center">
92
+ ⚠️ <strong>Runs commands as the PeerTube user. Enable in plugin settings first.</strong><br>
93
+ <span id="cs" style="display:inline-block;margin-top:6px;padding:3px 10px;border-radius:4px;font-size:13px;background:#f8d7da;color:#dc3545">DISABLED</span>
94
+ </div>
95
+ <div style="display:flex;gap:8px;margin-bottom:8px;align-items:center">
96
+ <span style="color:#aaa;font-size:12px;font-family:monospace">cwd:</span>
97
+ <input id="cwd" type="text" placeholder="/var/www/peertube (blank = default)" autocomplete="off"
98
+ style="flex:1;padding:6px 10px;background:#2d2d2d;border:1px solid #444;border-radius:4px;color:#fff;font-family:monospace;font-size:12px">
99
+ </div>
100
+ <div style="display:flex;gap:8px;margin-bottom:12px">
101
+ <div style="flex:1;display:flex;align-items:center;background:#2d2d2d;padding:8px 12px;border-radius:4px">
102
+ <span style="color:#4caf50;margin-right:8px;font-family:monospace;font-weight:bold">$</span>
103
+ <input id="ci" type="text" placeholder="command..." autocomplete="off"
104
+ style="flex:1;background:transparent;border:none;outline:none;color:#fff;font-family:monospace;font-size:13px">
105
+ </div>
106
+ <button id="rx" style="padding:8px 16px;background:#dc3545;color:#fff;border:none;border-radius:4px;cursor:pointer;font-weight:bold">Run</button>
107
+ <button id="cx" style="padding:8px 16px;background:#666;color:#fff;border:none;border-radius:4px;cursor:pointer">Clear</button>
108
+ </div>
109
+ <div id="to" style="background:#1e1e1e;color:#d4d4d4;min-height:250px;max-height:500px;overflow-y:auto;font-family:monospace;font-size:12px;padding:12px;border-radius:4px"></div>
110
+ </div>
111
+ </div>
112
+ `
113
+
114
+ // Tab switching
115
+ const panels = { logs:'p-logs', system:'p-system', env:'p-env', files:'p-files', terminal:'p-terminal' }
116
+ rootEl.querySelectorAll('.tb').forEach(b => b.addEventListener('click', () => {
117
+ rootEl.querySelectorAll('.tb').forEach(x => { x.style.color='#666'; x.style.borderBottom='none' })
118
+ b.style.color = '#f1680d'; b.style.borderBottom = '3px solid #f1680d'
119
+ Object.values(panels).forEach(id => rootEl.querySelector('#'+id).style.display = 'none')
120
+ rootEl.querySelector('#' + panels[b.dataset.t]).style.display = 'block'
121
+ }))
122
+
123
+ // Logs
124
+ let lf = ''
125
+ async function loadLogs () {
126
+ const out = rootEl.querySelector('#lo')
127
+ try {
128
+ const p = new URLSearchParams({ limit: 100 })
129
+ if (lf) p.append('type', lf)
130
+ const d = await api('logs?' + p)
131
+ if (!d.logs.length) { out.textContent = 'No logs yet.'; return }
132
+ out.innerHTML = d.logs.map(l => `
133
+ <div style="border-left:3px solid #3498db;padding:8px;margin-bottom:6px;background:#fff;border-radius:2px">
134
+ <div style="display:flex;justify-content:space-between;margin-bottom:2px">
135
+ <span style="color:#999;font-size:10px">${new Date(l.timestamp).toLocaleString()}</span>
136
+ <span style="font-size:10px;padding:1px 5px;background:#eee;border-radius:2px">${l.type}</span>
137
+ </div>
138
+ <div>${l.message}</div>
139
+ ${Object.keys(l.data||{}).length ? `<pre style="font-size:10px;background:#f5f5f5;padding:4px;margin-top:4px">${JSON.stringify(l.data,null,2)}</pre>` : ''}
140
+ </div>`).join('')
141
+ } catch(e) { out.textContent = 'Error: ' + e.message }
142
+ }
143
+ rootEl.querySelector('#rl').addEventListener('click', loadLogs)
144
+ rootEl.querySelector('#lf').addEventListener('change', e => { lf = e.target.value; loadLogs() })
145
+ rootEl.querySelector('#cl').addEventListener('click', async () => {
146
+ if (!confirm('Clear logs?')) return
147
+ try { await api('logs/clear', { method:'POST' }); loadLogs() } catch(e) { alert(e.message) }
148
+ })
149
+
150
+ // System
151
+ rootEl.querySelector('#rs').addEventListener('click', async () => {
152
+ const o = rootEl.querySelector('#so'); o.textContent = 'Loading...'
153
+ try { o.textContent = JSON.stringify(await api('system-info'), null, 2) } catch(e) { o.textContent = 'Error: '+e.message }
154
+ })
155
+
156
+ // Env
157
+ rootEl.querySelector('#re').addEventListener('click', async () => {
158
+ const o = rootEl.querySelector('#eo'); o.textContent = 'Loading...'
159
+ try { o.textContent = JSON.stringify(await api('env'), null, 2) } catch(e) { o.textContent = 'Error: '+e.message }
160
+ })
161
+
162
+ // Files
163
+ let cp = ''
164
+ const fpInput = rootEl.querySelector('#fp')
165
+ rootEl.querySelector('#fg').addEventListener('click', () => loadFiles(fpInput.value.trim()))
166
+ fpInput.addEventListener('keydown', e => { if (e.key === 'Enter') loadFiles(fpInput.value.trim()) })
167
+ async function loadFiles (p='') {
168
+ const out = rootEl.querySelector('#fo'); out.textContent = 'Loading...'
169
+ fpInput.value = p
170
+ try {
171
+ const d = await api('files?path=' + encodeURIComponent(p))
172
+ cp = d.path || ''
173
+ fpInput.value = cp
174
+ const parts = cp ? cp.split('/') : []
175
+ const bc = rootEl.querySelector('#bc')
176
+ bc.innerHTML = `<a href="#" data-p="" style="color:#f1680d">root</a>` +
177
+ parts.map((x,i) => ` / <a href="#" data-p="${parts.slice(0,i+1).join('/')}" style="color:#f1680d">${x}</a>`).join('')
178
+ bc.querySelectorAll('a').forEach(a => a.addEventListener('click', e => { e.preventDefault(); loadFiles(a.dataset.p) }))
179
+ out.innerHTML = d.items.map(item => `
180
+ <div style="display:flex;align-items:center;gap:8px;padding:7px;border-bottom:1px solid #eee">
181
+ <span>${item.type==='directory'?'📁':'📄'}</span>
182
+ <a href="#" data-p="${item.path}" data-type="${item.type}" style="flex:1;color:#f1680d;text-decoration:none">${item.name}</a>
183
+ <span style="color:#aaa;font-size:11px">${item.type==='file'?(item.size/1024).toFixed(1)+' KB':''}</span>
184
+ ${item.type==='file'?`<a href="#" data-dl="${item.path}" style="color:#888;font-size:11px;text-decoration:none;padding:2px 6px;border:1px solid #ddd;border-radius:3px" title="Download">⬇</a>`:''}
185
+ </div>`).join('')
186
+ out.querySelectorAll('a[data-p]').forEach(a => a.addEventListener('click', async e => {
187
+ e.preventDefault()
188
+ if (a.dataset.type === 'directory') { loadFiles(a.dataset.p); return }
189
+ try {
190
+ const f = await api('files/content?path=' + encodeURIComponent(a.dataset.p))
191
+ rootEl.querySelector('#ft').textContent = f.content
192
+ rootEl.querySelector('#fc').style.display = 'block'
193
+ } catch(err) { alert(err.message) }
194
+ }))
195
+ out.querySelectorAll('a[data-dl]').forEach(a => a.addEventListener('click', e => {
196
+ e.preventDefault()
197
+ const auth = peertubeHelpers.getAuthHeader() || {}
198
+ const url = `${base}/files/download?path=${encodeURIComponent(a.dataset.dl)}`
199
+ fetch(url, { headers: auth }).then(r => r.blob()).then(blob => {
200
+ const link = document.createElement('a')
201
+ link.href = URL.createObjectURL(blob)
202
+ link.download = a.dataset.dl.split('/').pop()
203
+ link.click()
204
+ URL.revokeObjectURL(link.href)
205
+ }).catch(err => alert('Download failed: ' + err.message))
206
+ }))
207
+ rootEl.querySelector('#fc').style.display = 'none'
208
+ } catch(e) { out.textContent = 'Error: '+e.message }
209
+ }
210
+ rootEl.querySelector('#rf').addEventListener('click', () => loadFiles(cp))
211
+ rootEl.querySelector('#xf').addEventListener('click', () => rootEl.querySelector('#fc').style.display = 'none')
212
+
213
+ // Terminal
214
+ async function checkCmd () {
215
+ try {
216
+ const d = await api('execute/status')
217
+ const el = rootEl.querySelector('#cs')
218
+ if (d.enabled) { el.textContent = '✓ ENABLED'; el.style.background='#d4edda'; el.style.color='#28a745' }
219
+ return d.enabled
220
+ } catch { return false }
221
+ }
222
+ async function runCmd () {
223
+ const cmd = rootEl.querySelector('#ci').value.trim()
224
+ if (!cmd) return
225
+ if (!await checkCmd()) { alert('Enable command execution in plugin settings first.'); return }
226
+ if (!confirm('Run on server: ' + cmd)) return
227
+ const out = rootEl.querySelector('#to')
228
+ const div = document.createElement('div')
229
+ div.style.cssText = 'margin-bottom:12px;padding-bottom:12px;border-bottom:1px solid #3d3d3d'
230
+ div.innerHTML = `<div style="color:#4caf50">$ ${cmd}</div><div style="color:#ffc107">running...</div>`
231
+ out.appendChild(div); out.scrollTop = out.scrollHeight
232
+ try {
233
+ const d = await api('execute', { method:'POST', body: JSON.stringify({ command: cmd, cwd: rootEl.querySelector('#cwd').value.trim() || undefined }) })
234
+ div.querySelector('div:last-child').remove()
235
+ const r = document.createElement('div')
236
+ r.style.cssText = `border-left:3px solid ${d.success?'#28a745':'#dc3545'};padding-left:8px`
237
+ if (d.stdout) r.innerHTML += `<pre style="margin:4px 0;white-space:pre-wrap">${d.stdout}</pre>`
238
+ if (d.stderr) r.innerHTML += `<pre style="margin:4px 0;color:#ffc107;white-space:pre-wrap">${d.stderr}</pre>`
239
+ r.innerHTML += `<div style="color:#666;font-size:11px;margin-top:4px">exit ${d.exitCode} · ${d.duration}ms</div>`
240
+ div.appendChild(r)
241
+ rootEl.querySelector('#ci').value = ''
242
+ } catch(e) { div.querySelector('div:last-child').textContent = '❌ '+e.message }
243
+ out.scrollTop = out.scrollHeight
244
+ }
245
+ rootEl.querySelector('#rx').addEventListener('click', runCmd)
246
+ rootEl.querySelector('#ci').addEventListener('keydown', e => { if(e.key==='Enter') runCmd() })
247
+ rootEl.querySelector('#cx').addEventListener('click', () => { if(confirm('Clear?')) rootEl.querySelector('#to').innerHTML='' })
248
+
249
+ // Init
250
+ loadLogs()
251
+ loadFiles()
252
+ checkCmd()
253
+ }
254
+ })
255
+ }
256
+
257
+ function unregister () {}
258
+
259
+ export { register, unregister }
package/main.js ADDED
@@ -0,0 +1,234 @@
1
+ const fs = require('fs').promises
2
+ const path = require('path')
3
+ const os = require('os')
4
+
5
+ async function register({
6
+ registerSetting,
7
+ settingsManager,
8
+ peertubeHelpers,
9
+ getRouter
10
+ }) {
11
+ const logger = peertubeHelpers.logger
12
+ const router = getRouter()
13
+
14
+ const adminOnly = async (req, res, next) => {
15
+ const user = await peertubeHelpers.user.getAuthUser(res)
16
+ if (!user || user.role !== 0) {
17
+ return res.status(403).json({ error: 'Admin access required' })
18
+ }
19
+ next()
20
+ }
21
+
22
+ registerSetting({
23
+ name: 'enable-command-execution',
24
+ label: 'Enable Command Execution (DANGER)',
25
+ type: 'input-checkbox',
26
+ default: false,
27
+ private: false
28
+ })
29
+
30
+ registerSetting({
31
+ name: 'command-timeout',
32
+ label: 'Command Timeout (seconds)',
33
+ type: 'input',
34
+ default: '30',
35
+ private: false
36
+ })
37
+
38
+ registerSetting({
39
+ name: 'allowed-paths',
40
+ label: 'Allowed File Browser Paths (comma-separated, use * for all)',
41
+ type: 'input-textarea',
42
+ default: '*',
43
+ private: false
44
+ })
45
+
46
+ // In-memory log store
47
+ const logs = []
48
+
49
+ const addLog = (type, message, data = {}) => {
50
+ logs.unshift({
51
+ timestamp: new Date().toISOString(),
52
+ type,
53
+ message,
54
+ data
55
+ })
56
+ if (logs.length > 1000) logs.length = 1000
57
+ }
58
+
59
+ // GET /logs
60
+ router.get('/logs', adminOnly, (req, res) => {
61
+ const { limit = 100, type } = req.query
62
+ let result = type ? logs.filter(l => l.type === type) : logs
63
+ res.json({ logs: result.slice(0, parseInt(limit)), total: result.length })
64
+ })
65
+
66
+ // POST /logs/clear
67
+ router.post('/logs/clear', adminOnly, (req, res) => {
68
+ const count = logs.length
69
+ logs.length = 0
70
+ res.json({ success: true, cleared: count })
71
+ })
72
+
73
+ // GET /system-info
74
+ router.get('/system-info', adminOnly, (req, res) => {
75
+ res.json({
76
+ platform: os.platform(),
77
+ arch: os.arch(),
78
+ nodeVersion: process.version,
79
+ hostname: os.hostname(),
80
+ uptime: os.uptime(),
81
+ processUptime: process.uptime(),
82
+ cpus: os.cpus().length,
83
+ totalMemory: os.totalmem(),
84
+ freeMemory: os.freemem(),
85
+ loadAverage: os.loadavg(),
86
+ processMemory: process.memoryUsage()
87
+ })
88
+ })
89
+
90
+ // GET /env
91
+ router.get('/env', adminOnly, (req, res) => {
92
+ const safe = {}
93
+ for (const [k, v] of Object.entries(process.env)) {
94
+ if (/^(PEERTUBE_|NODE_|DATABASE_|REDIS_)/.test(k)) {
95
+ safe[k] = /PASSWORD|SECRET|KEY/.test(k) ? '***' : v
96
+ }
97
+ }
98
+ res.json(safe)
99
+ })
100
+
101
+ // Helper to get allowed paths with fallback
102
+ const getAllowedPaths = async () => {
103
+ const raw = (await settingsManager.getSetting('allowed-paths')) || '*'
104
+ return raw.split(',').map(p => p.trim())
105
+ }
106
+
107
+ const isPathAllowed = (reqPath, allowed) => {
108
+ if (allowed.includes('*')) return true
109
+ return reqPath === '' || allowed.some(a => reqPath === a || reqPath.startsWith(a + '/'))
110
+ }
111
+
112
+ const resolvePath = (reqPath) => {
113
+ if (reqPath.startsWith('/')) return reqPath
114
+ return path.join(process.cwd(), reqPath)
115
+ }
116
+
117
+ // GET /files
118
+ router.get('/files', adminOnly, async (req, res) => {
119
+ try {
120
+ const allowed = await getAllowedPaths()
121
+ let reqPath = (req.query.path || '').replace(/\.\./g, '')
122
+ if (!isPathAllowed(reqPath, allowed)) {
123
+ return res.status(403).json({ error: 'Path not allowed', allowedPaths: allowed })
124
+ }
125
+
126
+ const full = resolvePath(reqPath)
127
+ const stat = await fs.stat(full)
128
+ if (!stat.isDirectory()) return res.status(400).json({ error: 'Not a directory' })
129
+
130
+ const entries = await fs.readdir(full, { withFileTypes: true })
131
+ const items = await Promise.all(entries.map(async e => {
132
+ const s = await fs.stat(path.join(full, e.name))
133
+ return {
134
+ name: e.name,
135
+ type: e.isDirectory() ? 'directory' : 'file',
136
+ size: s.size,
137
+ modified: s.mtime,
138
+ path: reqPath ? reqPath.replace(/\/$/, '') + '/' + e.name : e.name
139
+ }
140
+ }))
141
+ items.sort((a, b) => a.type !== b.type ? (a.type === 'directory' ? -1 : 1) : a.name.localeCompare(b.name))
142
+ res.json({ path: reqPath, items })
143
+ } catch (err) {
144
+ res.status(500).json({ error: err.message })
145
+ }
146
+ })
147
+
148
+ // GET /files/content
149
+ router.get('/files/content', adminOnly, async (req, res) => {
150
+ try {
151
+ const allowed = await getAllowedPaths()
152
+ let reqPath = (req.query.path || '').replace(/\.\./g, '')
153
+ if (!isPathAllowed(reqPath, allowed)) {
154
+ return res.status(403).json({ error: 'Path not allowed' })
155
+ }
156
+
157
+ const full = resolvePath(reqPath)
158
+ const stat = await fs.stat(full)
159
+ if (stat.size > 1024 * 1024) return res.status(400).json({ error: 'File too large (max 1MB)' })
160
+
161
+ const content = await fs.readFile(full, 'utf8')
162
+ res.json({ path: reqPath, content, size: stat.size })
163
+ } catch (err) {
164
+ res.status(500).json({ error: err.message })
165
+ }
166
+ })
167
+
168
+ // GET /files/download
169
+ router.get('/files/download', adminOnly, async (req, res) => {
170
+ try {
171
+ const allowed = await getAllowedPaths()
172
+ let reqPath = (req.query.path || '').replace(/\.\./g, '')
173
+ if (!isPathAllowed(reqPath, allowed)) {
174
+ return res.status(403).json({ error: 'Path not allowed' })
175
+ }
176
+ const full = resolvePath(reqPath)
177
+ const stat = await fs.stat(full)
178
+ if (stat.isDirectory()) return res.status(400).json({ error: 'Cannot download a directory' })
179
+ const filename = path.basename(full)
180
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`)
181
+ res.setHeader('Content-Length', stat.size)
182
+ const { createReadStream } = require('fs')
183
+ createReadStream(full).pipe(res)
184
+ } catch (err) {
185
+ res.status(500).json({ error: err.message })
186
+ }
187
+ })
188
+
189
+ // GET /health
190
+ router.get('/health', adminOnly, (req, res) => {
191
+ res.json({ status: 'ok', uptime: process.uptime(), logs: logs.length })
192
+ })
193
+
194
+ // GET /execute/status
195
+ router.get('/execute/status', adminOnly, async (req, res) => {
196
+ const enabled = await settingsManager.getSetting('enable-command-execution')
197
+ const timeout = parseInt(await settingsManager.getSetting('command-timeout') || '30') || 30
198
+ res.json({ enabled: enabled === true || enabled === 'true', timeout })
199
+ })
200
+
201
+ // POST /execute
202
+ router.post('/execute', adminOnly, async (req, res) => {
203
+ const enabled = await settingsManager.getSetting('enable-command-execution')
204
+ if (!enabled || enabled === 'false') {
205
+ return res.status(403).json({ error: 'Command execution is disabled.' })
206
+ }
207
+ const { command, cwd } = req.body
208
+ if (!command) return res.status(400).json({ error: 'Command required' })
209
+
210
+ const timeoutSec = parseInt(await settingsManager.getSetting('command-timeout') || '30') || 30
211
+ const { exec } = require('child_process')
212
+ const { promisify } = require('util')
213
+ const execAsync = promisify(exec)
214
+ const start = Date.now()
215
+
216
+ try {
217
+ const { stdout, stderr } = await execAsync(command, {
218
+ cwd: cwd || process.cwd(),
219
+ timeout: timeoutSec * 1000,
220
+ maxBuffer: 1024 * 1024
221
+ })
222
+ res.json({ success: true, stdout: stdout || '', stderr: stderr || '', exitCode: 0, duration: Date.now() - start })
223
+ } catch (e) {
224
+ res.json({ success: false, stdout: e.stdout || '', stderr: e.stderr || e.message, exitCode: e.code || 1, duration: Date.now() - start })
225
+ }
226
+ })
227
+
228
+ addLog('system', 'Static Review plugin loaded')
229
+ logger.info('Static Review plugin loaded')
230
+ }
231
+
232
+ async function unregister() {}
233
+
234
+ module.exports = { register, unregister }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "peertube-plugin-static-review",
3
+ "version": "1.0.3",
4
+ "description": "Admin dev tools for PeerTube — logs, system info, file browser, and terminal",
5
+ "main": "./main.js",
6
+ "library": "./main.js",
7
+ "author": "michaeljsjmartin",
8
+ "license": "MIT",
9
+ "engine": {
10
+ "peertube": ">=4.0.0"
11
+ },
12
+ "keywords": ["peertube", "plugin"],
13
+ "homepage": "https://github.com/michaeljsjmartin/peertube-plugin-static-review",
14
+ "bugs": "https://github.com/michaeljsjmartin/peertube-plugin-static-review/issues",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/michaeljsjmartin/peertube-plugin-static-review.git"
18
+ },
19
+ "clientScripts": [
20
+ {
21
+ "script": "client-scripts/client.js",
22
+ "scopes": ["common"]
23
+ }
24
+ ],
25
+ "css": [],
26
+ "staticDirs": {}
27
+ }