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 +94 -0
- package/client-scripts/client.js +259 -0
- package/main.js +234 -0
- package/package.json +27 -0
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
|
+
}
|