mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
feat: per-stack Reinstall + fix stack ownership enforcement
- API: stack-action/stack-remove now verify ownership for non-admin users - API: add stack-reinstall action (pull latest images → down → up) - User panel: add Reinstall button per stack; fix bug where remove-stack was called instead of stack-remove - Admin panel: add Reinstall button per stack + dockerStackReinstall() handler - User panel: Remove All My Apps now only removes the calling user's own containers/stacks Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -193,20 +193,42 @@ match ($action) {
|
|||||||
Response::success($result, 'Stack created');
|
Response::success($result, 'Stack created');
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
'stack-action' => (function() use ($dm, $body, $isAdmin) {
|
'stack-action' => (function() use ($dm, $body, $isAdmin, $_userAccountId) {
|
||||||
if (!$isAdmin) Response::error('Admin only', 403);
|
|
||||||
$id = (int)($body['stack_id'] ?? 0);
|
$id = (int)($body['stack_id'] ?? 0);
|
||||||
$act = $body['action'] ?? '';
|
$act = $body['action'] ?? '';
|
||||||
if (!$id || !$act) Response::error('stack_id and action required');
|
if (!$id || !$act) Response::error('stack_id and action required');
|
||||||
|
// Non-admins can only act on their own stacks
|
||||||
|
if (!$isAdmin) {
|
||||||
|
$stack = DB::getInstance()->fetchOne("SELECT account_id FROM docker_compose_stacks WHERE id = ?", [$id]);
|
||||||
|
if (!$stack || (int)$stack['account_id'] !== (int)$_userAccountId) Response::error('Not found', 404);
|
||||||
|
}
|
||||||
$out = $dm->composeAction($id, $act);
|
$out = $dm->composeAction($id, $act);
|
||||||
audit("docker.stack.{$act}", "stack:{$id}");
|
audit("docker.stack.{$act}", "stack:{$id}");
|
||||||
Response::success(['output' => $out]);
|
Response::success(['output' => $out]);
|
||||||
})(),
|
})(),
|
||||||
|
|
||||||
'stack-remove' => (function() use ($dm, $body, $isAdmin) {
|
'stack-reinstall' => (function() use ($dm, $body, $isAdmin, $_userAccountId) {
|
||||||
if (!$isAdmin) Response::error('Admin only', 403);
|
|
||||||
$id = (int)($body['stack_id'] ?? 0);
|
$id = (int)($body['stack_id'] ?? 0);
|
||||||
if (!$id) Response::error('stack_id required');
|
if (!$id) Response::error('stack_id required');
|
||||||
|
if (!$isAdmin) {
|
||||||
|
$stack = DB::getInstance()->fetchOne("SELECT account_id FROM docker_compose_stacks WHERE id = ?", [$id]);
|
||||||
|
if (!$stack || (int)$stack['account_id'] !== (int)$_userAccountId) Response::error('Not found', 404);
|
||||||
|
}
|
||||||
|
// Pull latest images, bring down (removing volumes), then start fresh
|
||||||
|
$dm->composeAction($id, 'pull');
|
||||||
|
$dm->composeAction($id, 'down');
|
||||||
|
$out = $dm->composeAction($id, 'up');
|
||||||
|
audit('docker.stack.reinstall', "stack:{$id}");
|
||||||
|
Response::success(['output' => $out], 'Stack reinstalled');
|
||||||
|
})(),
|
||||||
|
|
||||||
|
'stack-remove' => (function() use ($dm, $body, $isAdmin, $_userAccountId) {
|
||||||
|
$id = (int)($body['stack_id'] ?? 0);
|
||||||
|
if (!$id) Response::error('stack_id required');
|
||||||
|
if (!$isAdmin) {
|
||||||
|
$stack = DB::getInstance()->fetchOne("SELECT account_id FROM docker_compose_stacks WHERE id = ?", [$id]);
|
||||||
|
if (!$stack || (int)$stack['account_id'] !== (int)$_userAccountId) Response::error('Not found', 404);
|
||||||
|
}
|
||||||
$dm->removeStack($id);
|
$dm->removeStack($id);
|
||||||
audit('docker.stack.remove', "stack:{$id}");
|
audit('docker.stack.remove', "stack:{$id}");
|
||||||
Response::success(null, 'Stack removed');
|
Response::success(null, 'Stack removed');
|
||||||
|
|||||||
@@ -4143,6 +4143,7 @@ ${stacks.map(s=>`<tr>
|
|||||||
<button class="btn btn-xs btn-success" onclick="dockerStackAct(${s.id},'up')">Up</button>
|
<button class="btn btn-xs btn-success" onclick="dockerStackAct(${s.id},'up')">Up</button>
|
||||||
<button class="btn btn-xs btn-warning" onclick="dockerStackAct(${s.id},'down')">Down</button>
|
<button class="btn btn-xs btn-warning" onclick="dockerStackAct(${s.id},'down')">Down</button>
|
||||||
<button class="btn btn-xs btn-ghost" onclick="dockerStackAct(${s.id},'logs')">Logs</button>
|
<button class="btn btn-xs btn-ghost" onclick="dockerStackAct(${s.id},'logs')">Logs</button>
|
||||||
|
<button class="btn btn-xs btn-secondary" onclick="dockerStackReinstall(${s.id})">Reinstall</button>
|
||||||
<button class="btn btn-xs btn-danger" onclick="dockerStackRemove(${s.id})">Remove</button>
|
<button class="btn btn-xs btn-danger" onclick="dockerStackRemove(${s.id})">Remove</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`).join('')}
|
</tr>`).join('')}
|
||||||
@@ -4301,6 +4302,13 @@ window.dockerStackAct = async (id, action) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.dockerStackReinstall = (id) => Nova.confirm('Reinstall this stack? Latest images will be pulled and containers restarted. Data volumes are preserved.', async () => {
|
||||||
|
Nova.toast('Reinstalling stack…', 'info', 15000);
|
||||||
|
const r = await Nova.api('docker', 'stack-reinstall', { method: 'POST', body: { stack_id: id } });
|
||||||
|
Nova.toast(r?.success ? 'Stack reinstalled' : (r?.message||'Reinstall failed'), r?.success?'success':'error');
|
||||||
|
if (r?.success) dockerLoadTab('stacks');
|
||||||
|
}, true);
|
||||||
|
|
||||||
window.dockerStackRemove = (id) => Nova.confirm('Remove this stack? Docker Compose down will be run first.', async () => {
|
window.dockerStackRemove = (id) => Nova.confirm('Remove this stack? Docker Compose down will be run first.', async () => {
|
||||||
const r = await Nova.api('docker', 'stack-remove', { method: 'DELETE', body: { stack_id: id } });
|
const r = await Nova.api('docker', 'stack-remove', { method: 'DELETE', body: { stack_id: id } });
|
||||||
Nova.toast(r?.success ? 'Stack removed' : (r?.message||'Failed'), r?.success?'success':'error');
|
Nova.toast(r?.success ? 'Stack removed' : (r?.message||'Failed'), r?.success?'success':'error');
|
||||||
|
|||||||
@@ -1155,6 +1155,7 @@ ${stacks.map(s=>`<tr>
|
|||||||
? `<button class="btn btn-xs btn-warning" onclick="uStackAct(${s.id},'down')">Stop</button>`
|
? `<button class="btn btn-xs btn-warning" onclick="uStackAct(${s.id},'down')">Stop</button>`
|
||||||
: `<button class="btn btn-xs btn-success" onclick="uStackAct(${s.id},'up')">Start</button>`}
|
: `<button class="btn btn-xs btn-success" onclick="uStackAct(${s.id},'up')">Start</button>`}
|
||||||
<button class="btn btn-xs btn-ghost" onclick="uStackLogs(${s.id},'${Nova.escHtml(s.name)}')">Logs</button>
|
<button class="btn btn-xs btn-ghost" onclick="uStackLogs(${s.id},'${Nova.escHtml(s.name)}')">Logs</button>
|
||||||
|
<button class="btn btn-xs btn-secondary" onclick="uStackReinstall(${s.id},'${Nova.escHtml(s.name)}')">Reinstall</button>
|
||||||
<button class="btn btn-xs btn-danger" onclick="uStackRemove(${s.id},'${Nova.escHtml(s.name)}')">Remove</button>
|
<button class="btn btn-xs btn-danger" onclick="uStackRemove(${s.id},'${Nova.escHtml(s.name)}')">Remove</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`).join('')}
|
</tr>`).join('')}
|
||||||
@@ -1193,10 +1194,22 @@ window.uStackLogs = async (stackId, name) => {
|
|||||||
Nova.modal(`Logs: ${name}`, `<pre style="max-height:400px;overflow:auto;font-size:.78rem;white-space:pre-wrap">${Nova.escHtml(r?.data?.output||'No logs available')}</pre>`);
|
Nova.modal(`Logs: ${name}`, `<pre style="max-height:400px;overflow:auto;font-size:.78rem;white-space:pre-wrap">${Nova.escHtml(r?.data?.output||'No logs available')}</pre>`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.uStackReinstall = (stackId, name) => Nova.confirm(
|
||||||
|
`Reinstall "${name}"? This will pull the latest images, restart all containers, and reset to a fresh state. Your data volumes will be preserved.`,
|
||||||
|
async () => {
|
||||||
|
Nova.loading(`Reinstalling ${name}…`);
|
||||||
|
const r = await Nova.api('docker', 'stack-reinstall', { method: 'POST', body: { stack_id: stackId } });
|
||||||
|
Nova.loadingDone();
|
||||||
|
Nova.toast(r?.success ? `${name} reinstalled` : (r?.message || 'Reinstall failed'), r?.success ? 'success' : 'error');
|
||||||
|
if (r?.success) await uDockerReloadStacks();
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
window.uStackRemove = async (stackId, name) => {
|
window.uStackRemove = async (stackId, name) => {
|
||||||
if (!confirm(`Remove app "${name}"? This will stop and delete its containers and data.`)) return;
|
if (!confirm(`Remove app "${name}"? This will stop and delete its containers and data.`)) return;
|
||||||
Nova.loading('Removing app…');
|
Nova.loading('Removing app…');
|
||||||
const r = await Nova.api('docker', 'remove-stack', { method: 'POST', body: { stack_id: stackId } });
|
const r = await Nova.api('docker', 'stack-remove', { method: 'POST', body: { stack_id: stackId } });
|
||||||
Nova.loadingDone();
|
Nova.loadingDone();
|
||||||
Nova.toast(r?.success ? 'App removed' : (r?.message||'Failed'), r?.success?'success':'error');
|
Nova.toast(r?.success ? 'App removed' : (r?.message||'Failed'), r?.success?'success':'error');
|
||||||
if (r?.success) await uDockerReloadStacks();
|
if (r?.success) await uDockerReloadStacks();
|
||||||
|
|||||||
Reference in New Issue
Block a user