From 2f34ba578d93c200858159b5fb4fbcd3cbabb26e Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sun, 26 Jul 2026 12:07:14 -0400 Subject: [PATCH 01/11] fix(deps): unpin postcss override so security patches can resolve The exact `overrides.postcss: "8.5.16"` pin was the sole constraint holding postcss at a vulnerable version -- vite@8.1.4 (the only dependent) already declares `postcss: ^8.5.16`, so the override was redundant. It also made Dependabot fail with `security_update_not_possible`, reporting the pin back as though vite required it. Removing it resolves postcss to 8.5.23, above the 8.5.18 patched floor for GHSA-r28c-9q8g-f849 (path traversal in previous-source-map auto-loading). Co-Authored-By: Claude Opus 5 (1M context) --- frontend/package-lock.json | 8 ++++---- frontend/package.json | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d439fd1e..98bca9a0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5465,9 +5465,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -5485,7 +5485,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/frontend/package.json b/frontend/package.json index 4cdd86c9..d94e8997 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -64,7 +64,6 @@ "overrides": { "lodash": "4.18.1", "js-yaml": "4.3.0", - "postcss": "8.5.16", "@babel/core": "7.29.7", "ws": "8.21.0", "engine.io-client": "6.6.6" From 20e79ff3636aff37cec6d878618255b2e0d69ea7 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sun, 26 Jul 2026 15:19:08 -0400 Subject: [PATCH 02/11] fix(security): prevent command injection via hostname in Postfix install (GHSA-mc93-rc3x-fpgq) The hostname parameter was concatenated into a bash -c string executed as root via run_privileged(). Validate the hostname against a strict charset and pipe debconf selections via stdin so no user input ever reaches a shell command line. Co-authored-by: tonghuaroot --- backend/app/services/postfix_service.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/backend/app/services/postfix_service.py b/backend/app/services/postfix_service.py index ebbc9ce4..a90c9fe9 100644 --- a/backend/app/services/postfix_service.py +++ b/backend/app/services/postfix_service.py @@ -131,12 +131,18 @@ def get_status(cls) -> Dict: def install(cls, hostname: str = None) -> Dict: """Install Postfix.""" try: - # Pre-seed debconf to avoid interactive prompts + if hostname and not re.match(r'^[a-zA-Z0-9.-]+$', hostname): + return {'success': False, 'error': 'Invalid hostname format'} + + # Pre-seed debconf to avoid interactive prompts. The values are + # piped via stdin so no user input is ever interpolated into a + # shell command string (GHSA-mc93-rc3x-fpgq). if PackageManager.detect() == 'apt': - run_privileged(['bash', '-c', - 'echo "postfix postfix/mailname string ' + (hostname or 'localhost') + '" | debconf-set-selections']) - run_privileged(['bash', '-c', - 'echo "postfix postfix/main_mailer_type select Internet Site" | debconf-set-selections']) + debconf_lines = ( + f'postfix postfix/mailname string {hostname or "localhost"}\n' + 'postfix postfix/main_mailer_type select Internet Site\n' + ) + run_privileged(['debconf-set-selections'], input=debconf_lines) result = PackageManager.install(['postfix'], timeout=300) if result.returncode != 0: From 696226002b20213823de0ff56dea16ddbc42faf4 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sun, 26 Jul 2026 15:19:22 -0400 Subject: [PATCH 03/11] fix(security): enforce files.write permission on File Manager mutations (GHSA-4wqh-7f4f-5qmx) Mutating File Manager endpoints (write/create/mkdir/delete/rename/copy/ move/chmod/upload and the S3 equivalents) only required a valid JWT, so a viewer account (files.write=false) could write and delete files. They now require the files.write permission via permission_required(). Read endpoints are unchanged. Adds regression tests covering both advisories. Co-authored-by: AruvasgaChithan --- backend/app/api/files.py | 25 +++---- backend/tests/test_files_rbac.py | 113 +++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 12 deletions(-) create mode 100644 backend/tests/test_files_rbac.py diff --git a/backend/app/api/files.py b/backend/app/api/files.py index d8579b6a..b8da7aef 100644 --- a/backend/app/api/files.py +++ b/backend/app/api/files.py @@ -2,6 +2,7 @@ from flask import Blueprint, request, jsonify, send_file from flask_jwt_extended import jwt_required +from ..middleware.rbac import permission_required from ..services.file_service import FileService from ..services.storage_provider_service import StorageProviderService import os @@ -64,7 +65,7 @@ def read_file(): @files_bp.route('/write', methods=['POST']) -@jwt_required() +@permission_required('files', 'write') def write_file(): """Write content to a file.""" data = request.get_json() @@ -92,7 +93,7 @@ def write_file(): @files_bp.route('/create', methods=['POST']) -@jwt_required() +@permission_required('files', 'write') def create_file(): """Create a new file.""" data = request.get_json() @@ -116,7 +117,7 @@ def create_file(): @files_bp.route('/mkdir', methods=['POST']) -@jwt_required() +@permission_required('files', 'write') def create_directory(): """Create a new directory.""" data = request.get_json() @@ -139,7 +140,7 @@ def create_directory(): @files_bp.route('/delete', methods=['DELETE']) -@jwt_required() +@permission_required('files', 'write') def delete_path(): """Delete a file or directory.""" path = request.args.get('path') @@ -157,7 +158,7 @@ def delete_path(): @files_bp.route('/rename', methods=['POST']) -@jwt_required() +@permission_required('files', 'write') def rename_path(): """Rename a file or directory.""" data = request.get_json() @@ -181,7 +182,7 @@ def rename_path(): @files_bp.route('/copy', methods=['POST']) -@jwt_required() +@permission_required('files', 'write') def copy_path(): """Copy a file or directory.""" data = request.get_json() @@ -205,7 +206,7 @@ def copy_path(): @files_bp.route('/move', methods=['POST']) -@jwt_required() +@permission_required('files', 'write') def move_path(): """Move a file or directory.""" data = request.get_json() @@ -229,7 +230,7 @@ def move_path(): @files_bp.route('/chmod', methods=['POST']) -@jwt_required() +@permission_required('files', 'write') def change_permissions(): """Change file/directory permissions.""" data = request.get_json() @@ -358,7 +359,7 @@ def download_file(): @files_bp.route('/upload', methods=['POST']) -@jwt_required() +@permission_required('files', 'write') def upload_file(): """Upload a file.""" if 'file' not in request.files: @@ -439,7 +440,7 @@ def s3_read(): @files_bp.route('/s3/write', methods=['POST']) -@jwt_required() +@permission_required('files', 'write') def s3_write(): """Write (create or overwrite) an object from text content.""" data = request.get_json() or {} @@ -454,7 +455,7 @@ def s3_write(): @files_bp.route('/s3/delete', methods=['DELETE']) -@jwt_required() +@permission_required('files', 'write') def s3_delete(): """Delete an object (or every object beneath a prefix).""" path = request.args.get('path') @@ -476,7 +477,7 @@ def s3_download_url(): @files_bp.route('/s3/upload', methods=['POST']) -@jwt_required() +@permission_required('files', 'write') def s3_upload(): """Upload a file into the bucket at the given prefix.""" if 'file' not in request.files: diff --git a/backend/tests/test_files_rbac.py b/backend/tests/test_files_rbac.py new file mode 100644 index 00000000..ca17d32b --- /dev/null +++ b/backend/tests/test_files_rbac.py @@ -0,0 +1,113 @@ +"""Regression tests for two security advisories. + +GHSA-4wqh-7f4f-5qmx — the File Manager API only required a valid JWT, so a +viewer account (files.write=False) could write/delete files. Mutating +endpoints must now require the 'files' write permission. + +GHSA-mc93-rc3x-fpgq — PostfixService.install() interpolated the hostname into +a bash -c string. Hostnames are now validated and debconf values are piped +via stdin, never through a shell. +""" +import pytest + +from app import db as _db +from app.models import User +from app.services.postfix_service import PostfixService + + +@pytest.fixture +def role_headers(app): + """JWT headers for a viewer and a developer user.""" + from flask_jwt_extended import create_access_token + from werkzeug.security import generate_password_hash + + headers = {} + with app.app_context(): + for name, role in (('files_viewer', User.ROLE_VIEWER), + ('files_dev', User.ROLE_DEVELOPER)): + u = User(email=f'{name}@t.local', username=name, + password_hash=generate_password_hash('x'), + role=role, is_active=True) + _db.session.add(u) + _db.session.commit() + headers[role] = {'Authorization': f'Bearer {create_access_token(identity=u.id)}'} + return headers + + +# --------------------------------------------------------------------------- # +# GHSA-4wqh-7f4f-5qmx: viewer role blocked from file write/delete operations +# --------------------------------------------------------------------------- # + +WRITE_CASES = [ + ('post', '/api/v1/files/write', {'json': {'path': '/tmp/x', 'content': 'x'}}), + ('post', '/api/v1/files/create', {'json': {'path': '/tmp/x'}}), + ('post', '/api/v1/files/mkdir', {'json': {'path': '/tmp/x'}}), + ('delete', '/api/v1/files/delete?path=/tmp/x', {}), + ('post', '/api/v1/files/rename', {'json': {'path': '/tmp/x', 'new_name': 'y'}}), + ('post', '/api/v1/files/copy', {'json': {'src': '/tmp/x', 'dest': '/tmp/y'}}), + ('post', '/api/v1/files/move', {'json': {'src': '/tmp/x', 'dest': '/tmp/y'}}), + ('post', '/api/v1/files/chmod', {'json': {'path': '/tmp/x', 'mode': '755'}}), + ('post', '/api/v1/files/s3/write', {'json': {'path': '/x', 'content': 'x'}}), + ('delete', '/api/v1/files/s3/delete?path=/x', {}), +] + + +@pytest.mark.parametrize('method,url,kwargs', WRITE_CASES) +def test_viewer_cannot_mutate_files(client, role_headers, method, url, kwargs): + resp = getattr(client, method)(url, headers=role_headers[User.ROLE_VIEWER], **kwargs) + assert resp.status_code == 403 + assert 'files' in resp.get_json()['error'] + + +@pytest.mark.parametrize('method,url,kwargs', WRITE_CASES) +def test_developer_passes_files_write_gate(client, role_headers, monkeypatch, + method, url, kwargs): + """A role with files.write=True must get past the permission gate. + + The underlying services are stubbed so the test never touches the real + filesystem or S3; we only assert the gate does not return 403. + """ + from app.services.file_service import FileService + from app.services.storage_provider_service import StorageProviderService + + ok = {'success': True} + for name in ('write_file', 'create_file', 'create_directory', 'delete', + 'rename', 'copy', 'move', 'change_permissions'): + monkeypatch.setattr(FileService, name, staticmethod(lambda *a, **k: dict(ok))) + monkeypatch.setattr(StorageProviderService, 's3_write', + staticmethod(lambda *a, **k: dict(ok))) + monkeypatch.setattr(StorageProviderService, 's3_delete', + staticmethod(lambda *a, **k: dict(ok))) + + resp = getattr(client, method)(url, headers=role_headers[User.ROLE_DEVELOPER], **kwargs) + assert resp.status_code != 403 + + +def test_viewer_can_still_browse(client, role_headers): + """Read endpoints stay reachable for viewers (files.read=True).""" + resp = client.get('/api/v1/files/browse?path=/tmp', + headers=role_headers[User.ROLE_VIEWER]) + assert resp.status_code != 403 + + +# --------------------------------------------------------------------------- # +# GHSA-mc93-rc3x-fpgq: postfix install rejects malicious hostnames +# --------------------------------------------------------------------------- # + +@pytest.mark.parametrize('hostname', [ + '"; id > /tmp/pwned; echo "', + '$(id)', + '`id`', + 'mail.example.com; rm -rf /', + 'host name with spaces', +]) +def test_postfix_install_rejects_bad_hostname(hostname): + result = PostfixService.install(hostname=hostname) + assert result == {'success': False, 'error': 'Invalid hostname format'} + + +@pytest.mark.parametrize('hostname', ['mail.example.com', 'mx-1.example.org', 'localhost']) +def test_postfix_install_accepts_valid_hostname_format(hostname): + """Valid hostnames pass validation (install itself is not executed).""" + import re + assert re.match(r'^[a-zA-Z0-9.-]+$', hostname) From ddf2e948d1f17c8fd36c2168ea02086edb3a2e81 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sun, 26 Jul 2026 15:40:14 -0400 Subject: [PATCH 04/11] fix(security): enforce files.read on reads; de-shell syslog unit filter Follow-ups from the GHSA-4wqh-7f4f-5qmx / GHSA-mc93-rc3x-fpgq review: - File Manager read endpoints (browse/info/read/search/disk-*/analyze/ type-breakdown/download + s3/browse, s3/read, s3/download-url) only required a valid JWT, so a user whose files.read permission is revoked via custom permissions could still read and download files. They now require permission_required('files', 'read'), matching the write side. - LogService._read_syslog() interpolated the unit name into a bash -c string using subprocess.list2cmdline(), which is cmd.exe-style quoting and does not stop $(...)/backtick expansion under bash. grep now runs as a plain argv list (with --) and the tail happens in Python. Reachable only on non-systemd hosts by admins, but the same bug class as the Postfix advisory. Regression tests: revoked files.read 403s every read endpoint; the syslog filter never invokes a shell and passes payloads verbatim. --- backend/app/api/files.py | 25 ++++----- backend/app/services/log_service.py | 9 ++- backend/tests/test_files_rbac.py | 85 +++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 14 deletions(-) diff --git a/backend/app/api/files.py b/backend/app/api/files.py index b8da7aef..7e151aa3 100644 --- a/backend/app/api/files.py +++ b/backend/app/api/files.py @@ -1,7 +1,6 @@ """File Manager API endpoints for browsing, editing, and managing files.""" from flask import Blueprint, request, jsonify, send_file -from flask_jwt_extended import jwt_required from ..middleware.rbac import permission_required from ..services.file_service import FileService from ..services.storage_provider_service import StorageProviderService @@ -12,7 +11,7 @@ @files_bp.route('/browse', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def browse_directory(): """List directory contents.""" path = request.args.get('path', '/home') @@ -26,7 +25,7 @@ def browse_directory(): @files_bp.route('/info', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def get_file_info(): """Get information about a file or directory.""" path = request.args.get('path') @@ -47,7 +46,7 @@ def get_file_info(): @files_bp.route('/read', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def read_file(): """Read file contents.""" path = request.args.get('path') @@ -254,7 +253,7 @@ def change_permissions(): @files_bp.route('/search', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def search_files(): """Search for files matching a pattern.""" directory = request.args.get('directory', '/home') @@ -274,7 +273,7 @@ def search_files(): @files_bp.route('/disk-usage', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def get_disk_usage(): """Get disk usage for a path.""" path = request.args.get('path', '/') @@ -287,7 +286,7 @@ def get_disk_usage(): @files_bp.route('/disk-mounts', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def get_disk_mounts(): """Get disk usage for all mount points.""" result = FileService.get_all_disk_mounts() @@ -298,7 +297,7 @@ def get_disk_mounts(): @files_bp.route('/analyze', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def analyze_directory(): """Analyze directory sizes.""" path = request.args.get('path', '/home') @@ -315,7 +314,7 @@ def analyze_directory(): @files_bp.route('/type-breakdown', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def get_type_breakdown(): """Get file type breakdown for a directory.""" path = request.args.get('path', '/home') @@ -331,7 +330,7 @@ def get_type_breakdown(): @files_bp.route('/download', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def download_file(): """Download a file.""" path = request.args.get('path') @@ -420,7 +419,7 @@ def upload_file(): # ── S3 / object-storage browser (reuses the configured backup storage creds) ── @files_bp.route('/s3/browse', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def s3_browse(): """List a bucket prefix in the same entry shape as the local browser.""" path = request.args.get('path', '/') @@ -429,7 +428,7 @@ def s3_browse(): @files_bp.route('/s3/read', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def s3_read(): """Read a text object for in-app editing.""" path = request.args.get('path') @@ -466,7 +465,7 @@ def s3_delete(): @files_bp.route('/s3/download-url', methods=['GET']) -@jwt_required() +@permission_required('files', 'read') def s3_download_url(): """Return a short-lived presigned URL the browser can download directly.""" path = request.args.get('path') diff --git a/backend/app/services/log_service.py b/backend/app/services/log_service.py index 96498112..5a265e6f 100644 --- a/backend/app/services/log_service.py +++ b/backend/app/services/log_service.py @@ -315,8 +315,11 @@ def _read_syslog(cls, filepath: str, service: str, lines: int) -> Dict: """Read system logs from a syslog file, optionally filtering by service.""" try: if service: + # No shell: list2cmdline() is cmd.exe-style quoting and does + # not protect against $(...)/backtick expansion under bash. + # grep runs as argv and the tail happens in Python instead. result = run_privileged( - ['bash', '-c', f'grep -i {subprocess.list2cmdline([service])} {subprocess.list2cmdline([filepath])} | tail -n {int(lines)}'], + ['grep', '-i', '--', service, filepath], timeout=60, ) else: @@ -327,6 +330,10 @@ def _read_syslog(cls, filepath: str, service: str, lines: int) -> Dict: if result.returncode == 0 or (service and result.returncode == 1): log_lines = result.stdout.split('\n') if result.stdout else [] + if log_lines and log_lines[-1] == '': + log_lines.pop() # trailing newline artifact + if service: + log_lines = log_lines[-int(lines):] return sourced_result(log_lines, 'syslog', filepath) else: return {'success': False, 'error': result.stderr} diff --git a/backend/tests/test_files_rbac.py b/backend/tests/test_files_rbac.py index ca17d32b..0cf3b4ce 100644 --- a/backend/tests/test_files_rbac.py +++ b/backend/tests/test_files_rbac.py @@ -8,6 +8,8 @@ a bash -c string. Hostnames are now validated and debconf values are piped via stdin, never through a shell. """ +import subprocess + import pytest from app import db as _db @@ -90,6 +92,89 @@ def test_viewer_can_still_browse(client, role_headers): assert resp.status_code != 403 +# --------------------------------------------------------------------------- # +# files.read revocation: a user whose files.read permission is disabled must +# be blocked from read endpoints too, not just writes +# --------------------------------------------------------------------------- # + +READ_CASES = [ + '/api/v1/files/browse?path=/tmp', + '/api/v1/files/info?path=/tmp/x', + '/api/v1/files/read?path=/tmp/x', + '/api/v1/files/search?path=/tmp&query=x', + '/api/v1/files/download?path=/tmp/x', + '/api/v1/files/s3/browse?path=/', + '/api/v1/files/s3/read?path=/x', + '/api/v1/files/s3/download-url?path=/x', +] + + +@pytest.fixture +def no_read_headers(app): + """JWT headers for a viewer whose files.read permission is revoked.""" + from flask_jwt_extended import create_access_token + from werkzeug.security import generate_password_hash + + with app.app_context(): + u = User(email='files_noread@t.local', username='files_noread', + password_hash=generate_password_hash('x'), + role=User.ROLE_VIEWER, is_active=True) + perms = {f: {'read': True, 'write': False} for f in User.PERMISSION_FEATURES} + perms['files'] = {'read': False, 'write': False} + u.set_permissions(perms) + _db.session.add(u) + _db.session.commit() + return {'Authorization': f'Bearer {create_access_token(identity=u.id)}'} + + +@pytest.mark.parametrize('url', READ_CASES) +def test_revoked_files_read_blocks_read_endpoints(client, no_read_headers, url): + resp = client.get(url, headers=no_read_headers) + assert resp.status_code == 403 + assert 'files' in resp.get_json()['error'] + + +# --------------------------------------------------------------------------- # +# log_service._read_syslog: the service filter must never go through a shell +# (subprocess.list2cmdline is cmd.exe quoting and does not stop $(...)/backtick +# expansion under bash) +# --------------------------------------------------------------------------- # + +def test_read_syslog_does_not_use_a_shell(monkeypatch): + from app.services import log_service + from app.services.log_service import LogService + + captured = {} + + def fake_run_privileged(cmd, **kwargs): + captured['cmd'] = cmd + return subprocess.CompletedProcess(cmd, 0, stdout='match1\nmatch2\n', stderr='') + + monkeypatch.setattr(log_service, 'run_privileged', fake_run_privileged) + + payload = 'x$(id)`id`' + result = LogService._read_syslog('/var/log/syslog', payload, 100) + + cmd = captured['cmd'] + assert 'bash' not in cmd and '-c' not in cmd + # the payload travels as a standalone argv element, verbatim + assert payload in cmd + assert result['success'] + + +def test_read_syslog_tails_matches_in_python(monkeypatch): + from app.services import log_service + from app.services.log_service import LogService + + def fake_run_privileged(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 0, stdout='l1\nl2\nl3\n', stderr='') + + monkeypatch.setattr(log_service, 'run_privileged', fake_run_privileged) + + result = LogService._read_syslog('/var/log/syslog', 'nginx', 2) + assert result['lines'] == ['l2', 'l3'] + + # --------------------------------------------------------------------------- # # GHSA-mc93-rc3x-fpgq: postfix install rejects malicious hostnames # --------------------------------------------------------------------------- # From e4d35bffae1f6c7df3f58b46904a2c59f40ea67c Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sun, 26 Jul 2026 15:40:22 -0400 Subject: [PATCH 05/11] feat(files): hide File Manager nav when files.read is revoked Now that every /api/v1/files endpoint enforces the files.read permission, a user with that permission revoked would only hit 403 toasts. Filter the Files item out of the sidebar and the command palette (usePaletteAuthz) instead, using the resolved permissions already returned by /api/v1/auth/me. --- frontend/src/components/Sidebar.jsx | 10 ++++++++-- frontend/src/hooks/usePaletteAuthz.js | 7 +++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 9af0732b..20769f1f 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -13,7 +13,7 @@ import { sanitizeSvgInner } from '../utils/sanitizeSvg'; import useModules from '../hooks/useModules'; const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => {} }) => { - const { user, logout, updateUser } = useAuth(); + const { user, logout, updateUser, hasPermission } = useAuth(); const { theme, resolvedTheme, setTheme, whiteLabel } = useTheme(); const { layout, setLayout } = useLayout(); const navigate = useNavigate(); @@ -162,6 +162,12 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => { let items = [...core, ...fromPlugins].filter( (item) => !item.requiresCondition || conds[item.requiresCondition] ); + // Per-user feature permissions: a user whose files.read is revoked + // (custom permissions override the role template) gets 403s from every + // /api/v1/files endpoint, so don't surface the File Manager at all. + if (!hasPermission('files', 'read')) { + items = items.filter((item) => item.id !== 'files'); + } // Extension-contributed tab-group tabs (#43) keep the host group's // sidebar item lit on extension-owned tab routes (group id == sidebar // item id) — the core matchPrefixes only cover the group's own tabs. @@ -188,7 +194,7 @@ const Sidebar = ({ mobileOpen = false, isMobile = false, onMobileClose = () => { } } return applyWorkspaceNavPermissions(items, activeWorkspace, user); - }, [user?.sidebar_config, pluginNav, pluginTabs, wpInstalled, gpuAvailable, wordpressEnabled, user]); + }, [user?.sidebar_config, pluginNav, pluginTabs, wpInstalled, gpuAvailable, wordpressEnabled, user, hasPermission]); // Group visible items by category const groupedItems = useMemo(() => { diff --git a/frontend/src/hooks/usePaletteAuthz.js b/frontend/src/hooks/usePaletteAuthz.js index d7ac8e78..3dddd576 100644 --- a/frontend/src/hooks/usePaletteAuthz.js +++ b/frontend/src/hooks/usePaletteAuthz.js @@ -20,7 +20,7 @@ const ALWAYS_VISIBLE = new Set( * you hid from your sidebar for tidiness stays reachable via the palette. */ export default function usePaletteAuthz() { - const { isAdmin } = useAuth(); + const { isAdmin, hasPermission } = useAuth(); return useMemo(() => { let navMap = null; @@ -38,6 +38,9 @@ export default function usePaletteAuthz() { if (!navId) return true; if (isAdmin) return true; if (ALWAYS_VISIBLE.has(navId)) return true; + // Per-user feature permissions: files.read=false 403s every files + // endpoint, so don't offer the File Manager in the palette either. + if (navId === 'files' && !hasPermission('files', 'read')) return false; if (!navMap) return true; const allowed = navMap[role]; if (!Array.isArray(allowed) || allowed.length === 0) return true; @@ -52,5 +55,5 @@ export default function usePaletteAuthz() { return { isAdmin, allowNav, allowItem }; // Recomputes on login/role change (isAdmin flips); the workspace nav map // is read from localStorage at build time and isn't otherwise reactive. - }, [isAdmin]); + }, [isAdmin, hasPermission]); } From b6f689d57203f2c116a466722cf454cf2e59a964 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 26 Jul 2026 19:45:24 +0000 Subject: [PATCH 06/11] chore: bump version to 1.7.66 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 737a538d..257e32c3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.65 +1.7.66 From 88295ab0000840dd691d54fe052eaa773fac0ba0 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sun, 26 Jul 2026 16:18:37 -0400 Subject: [PATCH 07/11] fix(update): harden migration step against boot side-effects and slot venv mismatch - create_app(): skip background daemons (queue consumers, metrics, job system, analytics flush, linked-panel client) when loaded by a Flask CLI one-shot such as 'flask db upgrade', or when SERVERKIT_SKIP_BACKGROUND=1. They query the DB before migrations run, so any error there (corrupt DB, pre-migration schema) aborted the migration and sank the whole update. - update.sh: set SERVERKIT_SKIP_BACKGROUND=1 for both flask db upgrade invocations as an explicit contract. - update.sh: fail fast with an actionable message when the SQLite slot copy fails PRAGMA integrity_check, instead of dying mid-boot with 'database disk image is malformed'. - update.sh: rebuild the release tarball's prebuilt venv when its baked VIRTUAL_ENV path resolves to the old slot (it is baked as /opt/serverkit/venv), which previously ran new code on the old slot's dependencies during migration. Resolved-path comparison keeps the fast prebuilt path for fresh installs. --- backend/app/__init__.py | 154 ++++++++++++++++++++++------------------ scripts/update.sh | 52 ++++++++++++-- 2 files changed, 132 insertions(+), 74 deletions(-) diff --git a/backend/app/__init__.py b/backend/app/__init__.py index c3937fdd..880e2bea 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,4 +1,5 @@ import os +import sys from flask import Flask, send_from_directory, request, jsonify from flask_sqlalchemy import SQLAlchemy from flask_jwt_extended import JWTManager @@ -649,74 +650,91 @@ def create_app(config_name=None): import logging logging.getLogger(__name__).warning(f'Extension backend acquisition: {e}') - # Start metrics history collection in background - from app.services.metrics_history_service import MetricsHistoryService - if not MetricsHistoryService.is_running(): - MetricsHistoryService.start_collection(app) - - # Start queue-bus webhook consumer - from app.queue_bus.consumers import start_webhook_consumer - start_webhook_consumer(app) - - # Start queue-bus notification consumer (delivers in-app/email/chat) - from app.notifications.consumer import start_notification_consumer - start_notification_consumer(app) - - # Start the API analytics flush thread (a 5s buffer flush — a real-time - # stream, deliberately NOT modeled as a job). - from app.middleware.api_analytics import start_analytics_flush_thread - start_analytics_flush_thread(app) - - # Start the unified job system: ONE consumer runs every enqueued Job and - # ONE scheduler ticks all periodic work. This supersedes the former set - # of per-domain daemon scheduler threads (auto-sync, snapshot-retention, - # workflow, health-check, wp-update, api-background, pairing-prune, - # registrar-expiry) — they are now ScheduledJob rows backed by the - # built-in handlers in app/jobs/builtin_handlers.py. - from app.jobs import start_job_system - from app.jobs.builtin_handlers import register_builtin_handlers, seed_builtin_schedules - register_builtin_handlers() - # Register event-driven job handlers (deployment installs, workflow runs, - # scheduled backups). - from app.services.deployment_job_service import DeploymentJobService - DeploymentJobService.register_jobs() - # WorkflowEngine.register_jobs() removed in plan 45 Phase 4 (engine retired). - from app.services.backup_service import BackupService - BackupService.register_jobs() - from app.services.backup_policy_service import BackupPolicyService - BackupPolicyService.register_jobs() - from app.services.server_onboarding_service import ServerOnboardingService - ServerOnboardingService.register_jobs() - from app.services.preview_service import PreviewService - PreviewService.register_jobs() - from app.services.metadata_guard_service import MetadataGuardService - MetadataGuardService.register_jobs() - if not app.config.get('TESTING'): - MetadataGuardService.ensure() # converge the metadata egress rule (no-op when unsupported) - from app.services.speed_test_service import SpeedTestService - SpeedTestService.register_jobs() - from app.services import login_link_service - login_link_service.register_jobs() - from app.services.db_admin_sso_service import DbAdminSsoService - DbAdminSsoService.register_jobs() - from app.services.site_import_service import SiteImportService - SiteImportService.register_jobs() - from app.services.drift_service import DriftService - DriftService.register_jobs() - from app.services.doctor_service import DoctorService - DoctorService.register_jobs() - from app.services.file_integrity_service import FileIntegrityService - FileIntegrityService.register_jobs() - from app.services.malware_scan_service import MalwareScanService - MalwareScanService.register_jobs() - from app.services.bandwidth_service import BandwidthService - BandwidthService.register_jobs() - start_job_system(app, seed=seed_builtin_schedules) - - # Resume the embedded agent when this panel is linked to a master - # ServerKit panel (ServerKit-to-ServerKit peering). - from app.services.linked_panel_service import LinkedPanelService - LinkedPanelService.start_client_if_linked(app) + # Background daemons (metrics collector, queue consumers, analytics + # flush, the job system, the linked-panel client) only make sense in a + # long-running server process. When the app is loaded by a Flask CLI + # one-shot — crucially `flask db upgrade` during an update — they must + # NOT start: they query the database before migrations have run, and + # any failure there (a corrupt DB, or pre-migration schema the new code + # doesn't match yet) aborts the CLI command and sinks the whole update. + # SERVERKIT_SKIP_BACKGROUND=1 forces the same skip; the updater sets it + # as an explicit contract when running migrations. + _cli_one_shot = ( + os.path.basename(sys.argv[0] or '').startswith('flask') + and (len(sys.argv) < 2 or sys.argv[1] != 'run') + ) + _skip_background = ( + os.environ.get('SERVERKIT_SKIP_BACKGROUND') == '1' or _cli_one_shot + ) + if not _skip_background: + # Start metrics history collection in background + from app.services.metrics_history_service import MetricsHistoryService + if not MetricsHistoryService.is_running(): + MetricsHistoryService.start_collection(app) + + # Start queue-bus webhook consumer + from app.queue_bus.consumers import start_webhook_consumer + start_webhook_consumer(app) + + # Start queue-bus notification consumer (delivers in-app/email/chat) + from app.notifications.consumer import start_notification_consumer + start_notification_consumer(app) + + # Start the API analytics flush thread (a 5s buffer flush — a real-time + # stream, deliberately NOT modeled as a job). + from app.middleware.api_analytics import start_analytics_flush_thread + start_analytics_flush_thread(app) + + # Start the unified job system: ONE consumer runs every enqueued Job and + # ONE scheduler ticks all periodic work. This supersedes the former set + # of per-domain daemon scheduler threads (auto-sync, snapshot-retention, + # workflow, health-check, wp-update, api-background, pairing-prune, + # registrar-expiry) — they are now ScheduledJob rows backed by the + # built-in handlers in app/jobs/builtin_handlers.py. + from app.jobs import start_job_system + from app.jobs.builtin_handlers import register_builtin_handlers, seed_builtin_schedules + register_builtin_handlers() + # Register event-driven job handlers (deployment installs, workflow runs, + # scheduled backups). + from app.services.deployment_job_service import DeploymentJobService + DeploymentJobService.register_jobs() + # WorkflowEngine.register_jobs() removed in plan 45 Phase 4 (engine retired). + from app.services.backup_service import BackupService + BackupService.register_jobs() + from app.services.backup_policy_service import BackupPolicyService + BackupPolicyService.register_jobs() + from app.services.server_onboarding_service import ServerOnboardingService + ServerOnboardingService.register_jobs() + from app.services.preview_service import PreviewService + PreviewService.register_jobs() + from app.services.metadata_guard_service import MetadataGuardService + MetadataGuardService.register_jobs() + if not app.config.get('TESTING'): + MetadataGuardService.ensure() # converge the metadata egress rule (no-op when unsupported) + from app.services.speed_test_service import SpeedTestService + SpeedTestService.register_jobs() + from app.services import login_link_service + login_link_service.register_jobs() + from app.services.db_admin_sso_service import DbAdminSsoService + DbAdminSsoService.register_jobs() + from app.services.site_import_service import SiteImportService + SiteImportService.register_jobs() + from app.services.drift_service import DriftService + DriftService.register_jobs() + from app.services.doctor_service import DoctorService + DoctorService.register_jobs() + from app.services.file_integrity_service import FileIntegrityService + FileIntegrityService.register_jobs() + from app.services.malware_scan_service import MalwareScanService + MalwareScanService.register_jobs() + from app.services.bandwidth_service import BandwidthService + BandwidthService.register_jobs() + start_job_system(app, seed=seed_builtin_schedules) + + # Resume the embedded agent when this panel is linked to a master + # ServerKit panel (ServerKit-to-ServerKit peering). + from app.services.linked_panel_service import LinkedPanelService + LinkedPanelService.start_client_if_linked(app) # Request body size limit app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 # 100MB limit diff --git a/scripts/update.sh b/scripts/update.sh index 4cfe5828..16fa2373 100644 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -632,12 +632,32 @@ rebuild_virtualenv() { good "Virtual environment rebuilt at $target_dir" } -# Ensure the target directory has a usable venv. If a pre-built one exists, use -# it; otherwise rebuild from requirements. +# Ensure the target directory has a usable venv. If a pre-built one exists AND +# is bound to this slot's path, use it; otherwise rebuild from requirements. require_venv() { local target_dir="$1" if [ -f "$target_dir/bin/activate" ] && [ -x "$target_dir/bin/python" ]; then - good "Virtual environment ready at $target_dir" + # A venv is path-bound: bin/activate and every entry-point shebang + # embed the absolute path the venv was created at. The release + # tarball's prebuilt venv is baked as $INSTALL_DIR/venv, which — + # until atomic_switch flips the symlink — resolves to the OLD slot. + # Running it would execute the new code with the old slot's + # dependencies (and run the OLD flask binary for migrations), so a + # venv bound to any other path must be rebuilt in place. + local bound_path resolved_bound resolved_target + bound_path="$(sed -n 's/^VIRTUAL_ENV="\(.*\)"$/\1/p' "$target_dir/bin/activate" | head -1)" + # Compare RESOLVED paths: on a fresh install the symlink already points + # at the only slot, so the baked path resolves to the target and the + # prebuilt venv is genuinely usable (fast path). During an update it + # resolves to the OLD slot — that mismatch triggers the rebuild. + resolved_bound="$(readlink -f "$bound_path" 2>/dev/null || true)" + resolved_target="$(readlink -f "$target_dir" 2>/dev/null || echo "$target_dir")" + if [ -n "$resolved_bound" ] && [ "$resolved_bound" = "$resolved_target" ]; then + good "Virtual environment ready at $target_dir" + return 0 + fi + warn "Virtual environment at $target_dir is bound to '${bound_path:-unknown path}' — rebuilding for this slot" + rebuild_virtualenv "$target_dir" return 0 fi warn "Virtual environment missing at $target_dir" @@ -667,16 +687,36 @@ migrate_database() { # Subshell: the venv activation and the cd must not leak into the main # shell — the rest of the update keeps running from the caller's cwd. local slot_db="$work_dir/backend/instance/serverkit.db" + local use_slot_db=0 + if grep -qE '^DATABASE_URL=sqlite' "$work_dir/.env" 2>/dev/null && [ -f "$slot_db" ]; then + use_slot_db=1 + # Fail fast with an actionable message when the SQLite slot copy is + # corrupt, instead of dying deep inside app boot with "database disk + # image is malformed". The sqlite3 CLI isn't guaranteed to be + # installed — probe via the venv's Python instead. + local integrity + integrity="$("$venv/bin/python" -c \ + "import sqlite3,sys;print(sqlite3.connect(sys.argv[1]).execute('PRAGMA integrity_check').fetchone()[0])" \ + "$slot_db" 2>/dev/null || true)" + if [ "$integrity" != "ok" ]; then + halt "SQLite database is corrupt (${integrity:-integrity check failed to run}). Repair or restore it from $BACKUP_DIR and re-run the update. The previous installation is still active." + fi + fi if ! ( # shellcheck source=/dev/null source "$venv/bin/activate" cd "$work_dir/backend" - if grep -qE '^DATABASE_URL=sqlite' "$work_dir/.env" 2>/dev/null && [ -f "$slot_db" ]; then - DATABASE_URL="sqlite:///$slot_db" FLASK_ENV=production flask db upgrade + # SERVERKIT_SKIP_BACKGROUND=1: create_app() must not start queue + # consumers / schedulers / collectors while we only want migrations — + # they query the DB before `flask db upgrade` runs, and any error + # there aborts the migration (belt & braces; the app also detects the + # flask CLI on its own). + if [ "$use_slot_db" = "1" ]; then + DATABASE_URL="sqlite:///$slot_db" FLASK_ENV=production SERVERKIT_SKIP_BACKGROUND=1 flask db upgrade else # Non-SQLite (e.g. PostgreSQL): the DB is shared/external, so there # is no per-slot copy to isolate — migrate it directly. - FLASK_ENV=production flask db upgrade + FLASK_ENV=production SERVERKIT_SKIP_BACKGROUND=1 flask db upgrade fi ); then halt "Database migration failed. The previous installation is still active." From e3c4fa778dbe677d00146575387b83a9cd8e8cd6 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sun, 26 Jul 2026 16:25:12 -0400 Subject: [PATCH 08/11] =?UTF-8?q?fix(files):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20literal=20grep=20match,=20tail=20clamp,=20extend=20?= =?UTF-8?q?RBAC=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - log_service: grep the service/unit filter with -F so names like 'nginx.service' are matched literally instead of as a regex. - log_service: clamp the in-Python tail like 'tail -n' — lines=0 sliced as [-0:] == all lines, negatives kept nearly everything. - test_files_rbac: add files/upload and files/s3/upload to WRITE_CASES, and disk-usage/disk-mounts/analyze/type-breakdown to READ_CASES, so the permission gates on the full /api/v1/files surface are covered. --- backend/app/services/log_service.py | 9 +++++++-- backend/tests/test_files_rbac.py | 6 ++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/backend/app/services/log_service.py b/backend/app/services/log_service.py index 5a265e6f..3120bdc3 100644 --- a/backend/app/services/log_service.py +++ b/backend/app/services/log_service.py @@ -318,8 +318,10 @@ def _read_syslog(cls, filepath: str, service: str, lines: int) -> Dict: # No shell: list2cmdline() is cmd.exe-style quoting and does # not protect against $(...)/backtick expansion under bash. # grep runs as argv and the tail happens in Python instead. + # -F: the unit name is a literal string, not a regex — names + # like 'nginx.service' must not have '.' match any character. result = run_privileged( - ['grep', '-i', '--', service, filepath], + ['grep', '-F', '-i', '--', service, filepath], timeout=60, ) else: @@ -333,7 +335,10 @@ def _read_syslog(cls, filepath: str, service: str, lines: int) -> Dict: if log_lines and log_lines[-1] == '': log_lines.pop() # trailing newline artifact if service: - log_lines = log_lines[-int(lines):] + # Clamp like `tail -n`: -0 would slice as [0:] (all + # lines) and a negative count would keep nearly all. + tail_lines = max(0, int(lines)) + log_lines = log_lines[-tail_lines:] if tail_lines else [] return sourced_result(log_lines, 'syslog', filepath) else: return {'success': False, 'error': result.stderr} diff --git a/backend/tests/test_files_rbac.py b/backend/tests/test_files_rbac.py index 0cf3b4ce..8be34026 100644 --- a/backend/tests/test_files_rbac.py +++ b/backend/tests/test_files_rbac.py @@ -49,7 +49,9 @@ def role_headers(app): ('post', '/api/v1/files/copy', {'json': {'src': '/tmp/x', 'dest': '/tmp/y'}}), ('post', '/api/v1/files/move', {'json': {'src': '/tmp/x', 'dest': '/tmp/y'}}), ('post', '/api/v1/files/chmod', {'json': {'path': '/tmp/x', 'mode': '755'}}), + ('post', '/api/v1/files/upload', {}), ('post', '/api/v1/files/s3/write', {'json': {'path': '/x', 'content': 'x'}}), + ('post', '/api/v1/files/s3/upload', {}), ('delete', '/api/v1/files/s3/delete?path=/x', {}), ] @@ -102,6 +104,10 @@ def test_viewer_can_still_browse(client, role_headers): '/api/v1/files/info?path=/tmp/x', '/api/v1/files/read?path=/tmp/x', '/api/v1/files/search?path=/tmp&query=x', + '/api/v1/files/disk-usage', + '/api/v1/files/disk-mounts', + '/api/v1/files/analyze?path=/tmp', + '/api/v1/files/type-breakdown?path=/tmp', '/api/v1/files/download?path=/tmp/x', '/api/v1/files/s3/browse?path=/', '/api/v1/files/s3/read?path=/x', From 0129d7a13aed8168a68e2867a80e7b03ab06e44a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 26 Jul 2026 20:28:42 +0000 Subject: [PATCH 09/11] chore: bump version to 1.7.67 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 257e32c3..98f62112 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.66 +1.7.67 From 146113bd824aa4210fcce106612fa334aeec4c3a Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sun, 26 Jul 2026 16:35:39 -0400 Subject: [PATCH 10/11] fix(update): integrity probe distinguishes corruption from probe failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SQLite pre-check in migrate_database treated an EMPTY probe result (unusable venv python) the same as real corruption and halted the update — a false positive that broke the T10/T24 contract tests. Now: - probe output != 'ok' -> genuine corruption -> halt with repair guidance - empty probe output -> probe couldn't run -> warn and let flask surface any real error test_update.sh: T10/T24 fixtures stub the venv python (healthy-DB path); new T10b asserts the corrupt-DB halt fires before flask runs; new T10c asserts an unrunnable probe warns and proceeds. --- scripts/test/test_update.sh | 60 ++++++++++++++++++++++++++++++++++++- scripts/update.sh | 11 +++++-- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/scripts/test/test_update.sh b/scripts/test/test_update.sh index 991cf944..4260db5d 100644 --- a/scripts/test/test_update.sh +++ b/scripts/test/test_update.sh @@ -244,6 +244,8 @@ t="$WORK/t10/serverkit-b" mkdir -p "$t/venv/bin" "$t/backend/instance" : > "$t/venv/bin/activate" # sourceable no-op : > "$t/backend/instance/serverkit.db" # the slot's DB copy +printf '#!/usr/bin/env bash\necho ok\n' > "$t/venv/bin/python" # integrity probe: healthy DB +chmod +x "$t/venv/bin/python" printf 'DATABASE_URL=sqlite:///opt/serverkit/backend/instance/serverkit.db\n' > "$t/.env" FLASK_CAP="$WORK/t10/flask-saw-dburl" cat > "$STUB_BIN/flask" < "$t/venv/bin/activate" +: > "$t/backend/instance/serverkit.db" +printf 'DATABASE_URL=sqlite:///opt/serverkit/backend/instance/serverkit.db\n' > "$t/.env" +printf '#!/usr/bin/env bash\necho "database disk image is malformed"\n' > "$t/venv/bin/python" +chmod +x "$t/venv/bin/python" +FLASK_CAP="$WORK/t10b/flask-ran" +cat > "$STUB_BIN/flask" </dev/null 2>&1 || mig_rc=$? +if [ "$mig_rc" -ne 0 ] && [ ! -e "$FLASK_CAP" ]; then + ok "migrate_database halts on a corrupt SQLite DB before flask runs (fail-fast)" +else + bad "migrate_database corrupt-DB: rc=$mig_rc (want non-zero), flask-ran=$([ -e "$FLASK_CAP" ] && echo yes || echo no) (want no)" +fi +rm -f "$STUB_BIN/flask" + +# -------------------------------------------------------------------------- +# T10c — a probe that can't run at all (no usable venv python) is NOT proof +# of corruption: warn and proceed so flask can surface any real problem. +# -------------------------------------------------------------------------- +t="$WORK/t10c/serverkit-b" +mkdir -p "$t/venv/bin" "$t/backend/instance" +: > "$t/venv/bin/activate" +: > "$t/backend/instance/serverkit.db" +printf 'DATABASE_URL=sqlite:///opt/serverkit/backend/instance/serverkit.db\n' > "$t/.env" +printf '#!/usr/bin/env bash\nexit 0\n' > "$STUB_BIN/flask" +chmod +x "$STUB_BIN/flask" +mig_rc=0 +( + set -Eeuo pipefail + DRY_RUN=0 + migrate_database "$t" +) >/dev/null 2>&1 || mig_rc=$? +if [ "$mig_rc" -eq 0 ]; then + ok "migrate_database proceeds (with a warning) when the integrity probe can't run" +else + bad "migrate_database probe-unavailable: rc=$mig_rc, expected rc 0 (warn + proceed)" +fi +rm -f "$STUB_BIN/flask" # nginx and must NEVER stop it. Host nginx fronts every managed app, so a stop # during a panel update used to black out unrelated sites. A recording systemctl # stub (PATH-prepended ahead of the global stub) captures every invocation. @@ -701,6 +757,8 @@ t="$WORK/t24/serverkit-b" mkdir -p "$t/venv/bin" "$t/backend/instance" : > "$t/venv/bin/activate" : > "$t/backend/instance/serverkit.db" +printf '#!/usr/bin/env bash\necho ok\n' > "$t/venv/bin/python" # integrity probe: healthy DB +chmod +x "$t/venv/bin/python" printf 'DATABASE_URL=sqlite:////opt/serverkit/backend/instance/serverkit.db\n' > "$t/.env" printf '#!/usr/bin/env bash\nexit 0\n' > "$STUB_BIN/flask" chmod +x "$STUB_BIN/flask" diff --git a/scripts/update.sh b/scripts/update.sh index 16fa2373..20687ca7 100644 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -693,13 +693,18 @@ migrate_database() { # Fail fast with an actionable message when the SQLite slot copy is # corrupt, instead of dying deep inside app boot with "database disk # image is malformed". The sqlite3 CLI isn't guaranteed to be - # installed — probe via the venv's Python instead. + # installed — probe via the venv's Python instead. An EMPTY result + # means the probe itself couldn't run (no usable venv python) — that + # is not proof of corruption, so warn and let `flask db upgrade` + # surface any real problem rather than halting on a false positive. local integrity integrity="$("$venv/bin/python" -c \ "import sqlite3,sys;print(sqlite3.connect(sys.argv[1]).execute('PRAGMA integrity_check').fetchone()[0])" \ "$slot_db" 2>/dev/null || true)" - if [ "$integrity" != "ok" ]; then - halt "SQLite database is corrupt (${integrity:-integrity check failed to run}). Repair or restore it from $BACKUP_DIR and re-run the update. The previous installation is still active." + if [ -n "$integrity" ] && [ "$integrity" != "ok" ]; then + halt "SQLite database is corrupt ($integrity). Repair or restore it from $BACKUP_DIR and re-run the update. The previous installation is still active." + elif [ -z "$integrity" ]; then + warn "Could not pre-check SQLite integrity (venv python unavailable) — proceeding" fi fi if ! ( From 27e8004ce93fff7544c6660c4fa61757ad43a334 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 26 Jul 2026 20:36:05 +0000 Subject: [PATCH 11/11] chore: bump version to 1.7.68 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 98f62112..07a8425e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.67 +1.7.68