appmanager-server 0.3.0__tar.gz → 0.4.0__tar.gz
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.
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/CHANGELOG.md +14 -0
- appmanager_server-0.4.0/MANIFEST.in +17 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/PKG-INFO +1 -1
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/__init__.py +1 -1
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/admin/app_installer.py +46 -5
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/api/routes.py +23 -11
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/auth/routes.py +2 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/cli.py +13 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/config.py +19 -2
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/dependency_manager.py +66 -20
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/extension.py +23 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/middleware.py +38 -4
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/security.py +19 -4
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/templates/admin/dashboard.html +10 -13
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager_server.egg-info/PKG-INFO +1 -1
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager_server.egg-info/SOURCES.txt +0 -1
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/pyproject.toml +1 -1
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_dependency_manager.py +21 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_packaging_and_cli.py +2 -2
- appmanager_server-0.3.0/.env.example +0 -51
- appmanager_server-0.3.0/MANIFEST.in +0 -11
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/LICENSE +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/README.md +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/SECURITY.md +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/admin/__init__.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/admin/members.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/admin/registry.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/admin/routes.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/api/__init__.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/app_config.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/auth/__init__.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/auth/oauth.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/auth/utils.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/bridge.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/database.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/extensions.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/health.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/hooks.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/models.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/py.typed +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/sdk.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/security_scanner.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/signals.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/css/app.css +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/fonts.css +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-0.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-1.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-10.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-11.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-12.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-13.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-14.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-15.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-16.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-17.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-18.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-19.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-2.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-20.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-21.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-22.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-23.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-3.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-4.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-5.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-6.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-7.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-8.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/jetbrainsmono-9.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/spacegrotesk-0.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/spacegrotesk-1.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/spacegrotesk-10.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/spacegrotesk-11.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/spacegrotesk-2.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/spacegrotesk-3.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/spacegrotesk-4.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/spacegrotesk-5.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/spacegrotesk-6.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/spacegrotesk-7.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/spacegrotesk-8.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/spacegrotesk-9.woff2 +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/htmx/htmx.min.js +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/templates/admin/_member_drawer.html +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/templates/admin/_members_table.html +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/templates/admin/app_detail.html +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/templates/admin/flairs.html +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/templates/admin/permissions.html +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/templates/admin/roles.html +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/templates/admin/users.html +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/templates/auth/login.html +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/templates/auth/profile.html +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/templates/base.html +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/templates/errors/401.html +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/templates/errors/403.html +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/templates/errors/404.html +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/templates/errors/500.html +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/templates/index.html +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager_server.egg-info/dependency_links.txt +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager_server.egg-info/entry_points.txt +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager_server.egg-info/requires.txt +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager_server.egg-info/top_level.txt +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/requirements.txt +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/setup.cfg +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_api.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_app_settings.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_app_settings_config.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_app_updates.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_auth.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_cli_dev_and_templates.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_cli_health.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_cli_scaffolding.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_error_pages.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_extension_apps.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_extension_pattern.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_hooks.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_installer.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_invalid_install_failures.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_manifest_sdk.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_middleware.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_nested_portal.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_role_management.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_sdk.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_security_hardening.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_security_precheck_flow.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_security_scanner.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_signals.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_user_elevation_and_visibility.py +0 -0
- {appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_user_tracking.py +0 -0
|
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-08-28
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Automatic Sub-App Root Module Resolution**: Automatically prepends sub-app root directory (`app_dir`) to `sys.path` during dynamic module loading in `load_wsgi_app_from_path`, enabling seamless relative/module imports within sub-app packages.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## [0.3.1] - 2026-08-28
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- **App & Role Deletion Type-To-Confirm Modal**: Fixed modal validation logic and dynamic form URL in Admin Dashboard where typing the exact app/role slug did not enable the confirmation submission.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
10
24
|
## [0.3.0] - 2026-08-28
|
|
11
25
|
|
|
12
26
|
### Added
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
include LICENSE
|
|
2
|
+
include README.md
|
|
3
|
+
include CHANGELOG.md
|
|
4
|
+
include SECURITY.md
|
|
5
|
+
include requirements.txt
|
|
6
|
+
include .env.example
|
|
7
|
+
graft appmanager/templates
|
|
8
|
+
graft appmanager/static
|
|
9
|
+
global-exclude __pycache__
|
|
10
|
+
global-exclude *.py[cod]
|
|
11
|
+
global-exclude .env*
|
|
12
|
+
global-exclude *.db
|
|
13
|
+
global-exclude *.sqlite*
|
|
14
|
+
prune instance
|
|
15
|
+
prune installed_apps
|
|
16
|
+
prune .venv
|
|
17
|
+
prune .pytest_cache
|
|
@@ -2,6 +2,7 @@ import ast
|
|
|
2
2
|
import importlib.util
|
|
3
3
|
import inspect
|
|
4
4
|
import json
|
|
5
|
+
import logging
|
|
5
6
|
import os
|
|
6
7
|
import shutil
|
|
7
8
|
import subprocess
|
|
@@ -30,6 +31,8 @@ from appmanager.security import (
|
|
|
30
31
|
from appmanager.security_scanner import SecurityScanReport, run_security_scan
|
|
31
32
|
from appmanager.signals import subapp_installed, subapp_reloaded, subapp_uninstalled, subapp_updated
|
|
32
33
|
|
|
34
|
+
logger = logging.getLogger("appmanager.installer")
|
|
35
|
+
|
|
33
36
|
|
|
34
37
|
def sanitize_slug(slug_text: str) -> str:
|
|
35
38
|
clean = secure_filename(slug_text.lower().replace(" ", "-"))
|
|
@@ -217,19 +220,35 @@ def validate_subapp_package(path_or_zip: str) -> Tuple[bool, List[str], Dict[str
|
|
|
217
220
|
|
|
218
221
|
def discover_entrypoint(app_dir: str) -> str:
|
|
219
222
|
"""
|
|
220
|
-
Search for common Flask entrypoint files (app.py, wsgi.py, main.py, run.py
|
|
221
|
-
Returns 'module_name:app_variable' string e.g. 'app:app'
|
|
223
|
+
Search for common Flask entrypoint files (app.py, wsgi.py, main.py, run.py, src/app.py, packages)
|
|
224
|
+
or manifest.json. Returns 'module_name:app_variable' string e.g. 'app:app'
|
|
222
225
|
"""
|
|
223
226
|
manifest = parse_manifest(app_dir)
|
|
224
227
|
if manifest and "entry_point" in manifest:
|
|
225
228
|
return manifest["entry_point"]
|
|
226
229
|
|
|
227
|
-
candidates = [
|
|
230
|
+
candidates = [
|
|
231
|
+
"app.py",
|
|
232
|
+
"wsgi.py",
|
|
233
|
+
"main.py",
|
|
234
|
+
"run.py",
|
|
235
|
+
"server.py",
|
|
236
|
+
os.path.join("src", "app.py"),
|
|
237
|
+
os.path.join("src", "main.py"),
|
|
238
|
+
os.path.join("src", "wsgi.py"),
|
|
239
|
+
]
|
|
228
240
|
for candidate in candidates:
|
|
229
241
|
if os.path.exists(os.path.join(app_dir, candidate)):
|
|
230
|
-
module_name = candidate[:-3] # Strip .py
|
|
242
|
+
module_name = candidate[:-3].replace(os.sep, ".") # Strip .py and normalize slashes
|
|
231
243
|
return f"{module_name}:app"
|
|
232
244
|
|
|
245
|
+
# Check for package directories with __init__.py (e.g. appmanager, app, <slug>)
|
|
246
|
+
slug = os.path.basename(app_dir.rstrip("/\\"))
|
|
247
|
+
for pkg_cand in [slug, slug.replace("-", "_"), "app", "appmanager"]:
|
|
248
|
+
init_file = os.path.join(app_dir, pkg_cand, "__init__.py")
|
|
249
|
+
if os.path.exists(init_file):
|
|
250
|
+
return f"{pkg_cand}:create_app"
|
|
251
|
+
|
|
233
252
|
# Default fallback
|
|
234
253
|
return "app:app"
|
|
235
254
|
|
|
@@ -263,7 +282,9 @@ def load_wsgi_app_from_path(app_dir: str, entry_point: str = "app:app") -> Any:
|
|
|
263
282
|
|
|
264
283
|
module_path = module_path_or_err
|
|
265
284
|
if not os.path.exists(module_path):
|
|
266
|
-
raise FileNotFoundError(
|
|
285
|
+
raise FileNotFoundError(
|
|
286
|
+
f"Entrypoint module/file '{module_name}' was not found in sub-app package."
|
|
287
|
+
)
|
|
267
288
|
|
|
268
289
|
# Unique scoped module spec name to avoid sys.path pollution
|
|
269
290
|
slug = os.path.basename(app_dir)
|
|
@@ -275,6 +296,8 @@ def load_wsgi_app_from_path(app_dir: str, entry_point: str = "app:app") -> Any:
|
|
|
275
296
|
|
|
276
297
|
mod = importlib.util.module_from_spec(spec)
|
|
277
298
|
sys.modules[scoped_module_name] = mod
|
|
299
|
+
if app_dir not in sys.path:
|
|
300
|
+
sys.path.insert(0, app_dir)
|
|
278
301
|
spec.loader.exec_module(mod)
|
|
279
302
|
|
|
280
303
|
# Check for Flask app instance, callable create_app factory, or WSGI middleware
|
|
@@ -402,6 +425,7 @@ def stage_git_repo(
|
|
|
402
425
|
if not is_safe_repo_url(repo_url):
|
|
403
426
|
raise ValueError(f"Invalid or unsafe git repository URL: '{repo_url}'")
|
|
404
427
|
|
|
428
|
+
logger.info("Staging Git repository installation from '%s'", repo_url)
|
|
405
429
|
cleanup_expired_staged_sessions()
|
|
406
430
|
|
|
407
431
|
import tempfile
|
|
@@ -426,6 +450,7 @@ def stage_git_repo(
|
|
|
426
450
|
res.stderr.strip() or f"Failed to clone repository from '{repo_url}'."
|
|
427
451
|
)
|
|
428
452
|
except Exception as git_err:
|
|
453
|
+
logger.error("Failed to clone Git repository '%s': %s", repo_url, git_err)
|
|
429
454
|
if os.path.exists(temp_stage_dir):
|
|
430
455
|
shutil.rmtree(temp_stage_dir, ignore_errors=True)
|
|
431
456
|
raise ValueError(f"Failed to clone Git repository: {git_err}")
|
|
@@ -471,6 +496,13 @@ def stage_git_repo(
|
|
|
471
496
|
"created_at": time.time(),
|
|
472
497
|
}
|
|
473
498
|
_write_staging_metadata(temp_stage_dir, _staged_installations[staging_id])
|
|
499
|
+
logger.info(
|
|
500
|
+
"Staged Git app '%s' (staging_id=%s, risk=%s, safe=%s)",
|
|
501
|
+
resolved_slug,
|
|
502
|
+
staging_id,
|
|
503
|
+
scan_report.risk_level,
|
|
504
|
+
scan_report.is_safe,
|
|
505
|
+
)
|
|
474
506
|
|
|
475
507
|
return staging_id, scan_report, manifest
|
|
476
508
|
|
|
@@ -553,6 +585,13 @@ def stage_zip_file(
|
|
|
553
585
|
"created_at": time.time(),
|
|
554
586
|
}
|
|
555
587
|
_write_staging_metadata(temp_stage_dir, _staged_installations[staging_id])
|
|
588
|
+
logger.info(
|
|
589
|
+
"Staged ZIP package '%s' (filename: %s, risk=%s, safe=%s)",
|
|
590
|
+
resolved_slug,
|
|
591
|
+
filename,
|
|
592
|
+
scan_report.risk_level,
|
|
593
|
+
scan_report.is_safe,
|
|
594
|
+
)
|
|
556
595
|
|
|
557
596
|
return staging_id, scan_report, manifest
|
|
558
597
|
|
|
@@ -581,6 +620,7 @@ def finalize_staged_installation(
|
|
|
581
620
|
|
|
582
621
|
final_slug = sanitize_slug(slug or session.get("slug") or session.get("name") or "sub-app")
|
|
583
622
|
target_dir = os.path.join(current_app.config["INSTALLED_APPS_DIR"], final_slug)
|
|
623
|
+
logger.info("Finalizing installation of staged app '%s' into '%s'", final_slug, target_dir)
|
|
584
624
|
|
|
585
625
|
if os.path.exists(target_dir):
|
|
586
626
|
if os.path.exists(staging_dir):
|
|
@@ -631,6 +671,7 @@ def finalize_staged_installation(
|
|
|
631
671
|
else:
|
|
632
672
|
initial_settings[s_key] = s_val
|
|
633
673
|
|
|
674
|
+
logger.info("Installing dependencies for '%s'...", final_slug)
|
|
634
675
|
install_dependencies(target_dir)
|
|
635
676
|
|
|
636
677
|
installed_app = InstalledApp(
|
|
@@ -73,22 +73,34 @@ def system_health():
|
|
|
73
73
|
|
|
74
74
|
db.session.execute(text("SELECT 1"))
|
|
75
75
|
except Exception as e:
|
|
76
|
+
import logging
|
|
77
|
+
|
|
78
|
+
logging.getLogger("appmanager.api").exception(f"Database health check failed: {e}")
|
|
76
79
|
db_ok = False
|
|
77
|
-
|
|
80
|
+
if current_app.config.get("DEBUG", False):
|
|
81
|
+
db_error = str(e)
|
|
78
82
|
|
|
79
|
-
total_apps =
|
|
80
|
-
active_apps =
|
|
83
|
+
total_apps = 0
|
|
84
|
+
active_apps = 0
|
|
85
|
+
if db_ok:
|
|
86
|
+
try:
|
|
87
|
+
total_apps = InstalledApp.query.count()
|
|
88
|
+
active_apps = InstalledApp.query.filter_by(is_active=True).count()
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
81
91
|
|
|
82
92
|
overall_status = "healthy" if db_ok else "unhealthy"
|
|
83
93
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
resp_data = {
|
|
95
|
+
"status": overall_status,
|
|
96
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
97
|
+
"database": {"connected": db_ok},
|
|
98
|
+
"apps": {"total": total_apps, "active": active_apps},
|
|
99
|
+
}
|
|
100
|
+
if db_error is not None:
|
|
101
|
+
resp_data["database"]["error"] = db_error
|
|
102
|
+
|
|
103
|
+
return jsonify(resp_data), 200 if db_ok else 503
|
|
92
104
|
|
|
93
105
|
|
|
94
106
|
@api_bp.route("/apps", methods=["GET"])
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import logging
|
|
1
2
|
from datetime import datetime, timezone
|
|
2
3
|
|
|
3
4
|
from flask import (
|
|
@@ -23,6 +24,7 @@ from appmanager.database import db
|
|
|
23
24
|
from appmanager.models import InstalledApp, MagicLinkToken, User, UserAppPermission
|
|
24
25
|
from appmanager.security import check_rate_limit, is_safe_redirect_url
|
|
25
26
|
|
|
27
|
+
logger = logging.getLogger("appmanager.auth")
|
|
26
28
|
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
|
|
27
29
|
|
|
28
30
|
|
|
@@ -983,6 +983,19 @@ def check_deps_cli(
|
|
|
983
983
|
query = query.filter_by(slug=slug)
|
|
984
984
|
apps = query.all()
|
|
985
985
|
|
|
986
|
+
if not apps and slug and slug.lower() != "all":
|
|
987
|
+
target_dir = os.path.join(installed_apps_dir, slug) if not os.path.isabs(slug) else slug
|
|
988
|
+
if os.path.isdir(target_dir):
|
|
989
|
+
manifest = parse_manifest(target_dir) or {}
|
|
990
|
+
app_name = manifest.get("name", slug)
|
|
991
|
+
apps = [
|
|
992
|
+
type(
|
|
993
|
+
"DiskApp",
|
|
994
|
+
(),
|
|
995
|
+
{"name": app_name, "slug": os.path.basename(target_dir.rstrip("/"))},
|
|
996
|
+
)()
|
|
997
|
+
]
|
|
998
|
+
|
|
986
999
|
if not apps:
|
|
987
1000
|
print("No matching installed applications found.\n")
|
|
988
1001
|
return 0
|
|
@@ -6,7 +6,17 @@ from dotenv import load_dotenv
|
|
|
6
6
|
# Automatically load .env if present in current working directory or base directory
|
|
7
7
|
load_dotenv()
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
# Resolve base directory: when running as installed package, use current working directory
|
|
10
|
+
_pkg_root = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
|
|
11
|
+
if (
|
|
12
|
+
"site-packages" in _pkg_root
|
|
13
|
+
or "dist-packages" in _pkg_root
|
|
14
|
+
or not os.path.exists(os.path.join(_pkg_root, "pyproject.toml"))
|
|
15
|
+
):
|
|
16
|
+
DEFAULT_BASE_DIR = os.path.abspath(os.getcwd())
|
|
17
|
+
else:
|
|
18
|
+
DEFAULT_BASE_DIR = _pkg_root
|
|
19
|
+
|
|
10
20
|
BASE_DIR = os.getenv("APPMANAGER_BASE_DIR", DEFAULT_BASE_DIR)
|
|
11
21
|
|
|
12
22
|
|
|
@@ -73,9 +83,16 @@ class Config:
|
|
|
73
83
|
|
|
74
84
|
# Security settings
|
|
75
85
|
SESSION_COOKIE_HTTPONLY = True
|
|
76
|
-
SESSION_COOKIE_SAMESITE =
|
|
86
|
+
SESSION_COOKIE_SAMESITE = "Lax"
|
|
77
87
|
SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "false").lower() in (
|
|
78
88
|
"true",
|
|
79
89
|
"1",
|
|
80
90
|
"yes",
|
|
81
91
|
)
|
|
92
|
+
|
|
93
|
+
# Logging Configuration
|
|
94
|
+
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
|
95
|
+
LOG_FORMAT = os.getenv(
|
|
96
|
+
"LOG_FORMAT",
|
|
97
|
+
"[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s",
|
|
98
|
+
)
|
|
@@ -282,12 +282,18 @@ def install_app_dependencies(
|
|
|
282
282
|
timeout: int = 180,
|
|
283
283
|
) -> Tuple[bool, str]:
|
|
284
284
|
"""
|
|
285
|
-
Installs sub-app dependencies from requirements.txt
|
|
285
|
+
Installs sub-app dependencies from requirements.txt and installs the sub-app itself
|
|
286
|
+
into the virtual environment if it is an installable Python package (has pyproject.toml or setup.py).
|
|
286
287
|
Returns (success, output_or_error_message).
|
|
287
288
|
"""
|
|
288
289
|
req_file = os.path.join(app_dir, "requirements.txt")
|
|
289
|
-
|
|
290
|
-
|
|
290
|
+
has_reqs = os.path.exists(req_file)
|
|
291
|
+
has_pkg = os.path.exists(os.path.join(app_dir, "pyproject.toml")) or os.path.exists(
|
|
292
|
+
os.path.join(app_dir, "setup.py")
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
if not has_reqs and not has_pkg:
|
|
296
|
+
return True, "No requirements.txt or pyproject.toml found (nothing to install)."
|
|
291
297
|
|
|
292
298
|
if venv_mode == "isolated":
|
|
293
299
|
venv_dir = os.path.join(app_dir, ".venv")
|
|
@@ -314,26 +320,66 @@ def install_app_dependencies(
|
|
|
314
320
|
except Exception:
|
|
315
321
|
pass
|
|
316
322
|
|
|
317
|
-
|
|
323
|
+
pip_base = [pip_path, "install"]
|
|
318
324
|
else:
|
|
319
325
|
# Singular shared host virtual environment
|
|
320
|
-
|
|
326
|
+
pip_base = [sys.executable, "-m", "pip", "install"]
|
|
321
327
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
328
|
+
outputs: List[str] = []
|
|
329
|
+
|
|
330
|
+
# 1. Install requirements.txt if present
|
|
331
|
+
if has_reqs:
|
|
332
|
+
try:
|
|
333
|
+
proc = subprocess.run(
|
|
334
|
+
pip_base + ["-r", req_file],
|
|
335
|
+
capture_output=True,
|
|
336
|
+
text=True,
|
|
337
|
+
timeout=timeout,
|
|
338
|
+
)
|
|
339
|
+
if proc.returncode != 0:
|
|
340
|
+
return (
|
|
341
|
+
False,
|
|
342
|
+
f"Pip requirements installation failed (code {proc.returncode}): {proc.stderr}",
|
|
343
|
+
)
|
|
344
|
+
if proc.stdout:
|
|
345
|
+
outputs.append(proc.stdout.strip())
|
|
346
|
+
except subprocess.TimeoutExpired:
|
|
347
|
+
return False, f"Dependency installation timed out after {timeout} seconds."
|
|
348
|
+
except Exception as e:
|
|
349
|
+
return False, f"Unexpected error during requirements installation: {str(e)}"
|
|
350
|
+
|
|
351
|
+
# 2. Install package itself if installable (pyproject.toml or setup.py present)
|
|
352
|
+
if has_pkg:
|
|
353
|
+
try:
|
|
354
|
+
# Attempt editable install, fallback to standard directory install
|
|
355
|
+
proc = subprocess.run(
|
|
356
|
+
pip_base + ["-e", app_dir],
|
|
357
|
+
capture_output=True,
|
|
358
|
+
text=True,
|
|
359
|
+
timeout=timeout,
|
|
360
|
+
)
|
|
361
|
+
if proc.returncode != 0:
|
|
362
|
+
proc = subprocess.run(
|
|
363
|
+
pip_base + [app_dir],
|
|
364
|
+
capture_output=True,
|
|
365
|
+
text=True,
|
|
366
|
+
timeout=timeout,
|
|
367
|
+
)
|
|
368
|
+
if proc.returncode != 0:
|
|
369
|
+
return (
|
|
370
|
+
False,
|
|
371
|
+
f"Package self-installation failed (code {proc.returncode}): {proc.stderr}",
|
|
372
|
+
)
|
|
373
|
+
if proc.stdout:
|
|
374
|
+
outputs.append(proc.stdout.strip())
|
|
375
|
+
except subprocess.TimeoutExpired:
|
|
376
|
+
return False, f"Package installation timed out after {timeout} seconds."
|
|
377
|
+
except Exception as e:
|
|
378
|
+
return False, f"Unexpected error during package installation: {str(e)}"
|
|
379
|
+
|
|
380
|
+
return True, "\n".join(
|
|
381
|
+
outputs
|
|
382
|
+
) if outputs else "Package and dependencies installed successfully."
|
|
337
383
|
|
|
338
384
|
|
|
339
385
|
def analyze_uninstall_dependencies(
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import logging
|
|
1
2
|
import os
|
|
2
3
|
from typing import Any, List, Optional, Union
|
|
3
4
|
|
|
@@ -15,6 +16,26 @@ from appmanager.middleware import DynamicAppDispatcherMiddleware
|
|
|
15
16
|
from appmanager.models import InstalledApp, UserAppPermission
|
|
16
17
|
|
|
17
18
|
|
|
19
|
+
def _setup_logging(app: Flask) -> None:
|
|
20
|
+
"""
|
|
21
|
+
Configures structured logging for the AppManager package.
|
|
22
|
+
"""
|
|
23
|
+
level_name = app.config.get("LOG_LEVEL", "INFO").upper()
|
|
24
|
+
log_level = getattr(logging, level_name, logging.INFO)
|
|
25
|
+
log_format = app.config.get(
|
|
26
|
+
"LOG_FORMAT",
|
|
27
|
+
"[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("appmanager")
|
|
31
|
+
logger.setLevel(log_level)
|
|
32
|
+
|
|
33
|
+
if not logger.handlers:
|
|
34
|
+
handler = logging.StreamHandler()
|
|
35
|
+
handler.setFormatter(logging.Formatter(log_format))
|
|
36
|
+
logger.addHandler(handler)
|
|
37
|
+
|
|
38
|
+
|
|
18
39
|
class AppManager:
|
|
19
40
|
"""
|
|
20
41
|
Standard Flask extension class for AppManager.
|
|
@@ -53,6 +74,8 @@ class AppManager:
|
|
|
53
74
|
if key.isupper() and key not in app.config:
|
|
54
75
|
app.config[key] = getattr(Config, key)
|
|
55
76
|
|
|
77
|
+
_setup_logging(app)
|
|
78
|
+
|
|
56
79
|
# Ensure base directories exist
|
|
57
80
|
base_dir = app.config.get("BASE_DIR", Config.BASE_DIR)
|
|
58
81
|
os.makedirs(app.config["INSTALLED_APPS_DIR"], exist_ok=True)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import logging
|
|
1
2
|
import os
|
|
2
3
|
import re
|
|
3
4
|
from typing import Any, Callable, Dict, Optional
|
|
@@ -10,6 +11,7 @@ from appmanager.database import db
|
|
|
10
11
|
from appmanager.models import InstalledApp, User, UserAppPermission
|
|
11
12
|
from appmanager.signals import subapp_reloaded
|
|
12
13
|
|
|
14
|
+
logger = logging.getLogger("appmanager.dispatcher")
|
|
13
15
|
VALID_SLUG_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$")
|
|
14
16
|
|
|
15
17
|
|
|
@@ -206,22 +208,42 @@ class DynamicAppDispatcherMiddleware:
|
|
|
206
208
|
environ["HTTP_X_APPMANAGER_SUBAPP_SLUG"] = slug
|
|
207
209
|
environ["HTTP_X_FORWARDED_PREFIX"] = f"/apps/{slug}"
|
|
208
210
|
|
|
211
|
+
logger.debug(
|
|
212
|
+
"Dispatching request '%s %s' to sub-app '%s' (user: %s)",
|
|
213
|
+
environ.get("REQUEST_METHOD", "GET"),
|
|
214
|
+
path_info,
|
|
215
|
+
slug,
|
|
216
|
+
user.email if user else "anonymous",
|
|
217
|
+
)
|
|
218
|
+
|
|
209
219
|
# 3. Load or Retrieve Sub-App WSGI Instance
|
|
210
220
|
app_dir = os.path.join(
|
|
211
221
|
self.main_app.config["INSTALLED_APPS_DIR"], app_record.slug
|
|
212
222
|
)
|
|
213
223
|
if not os.path.exists(app_dir):
|
|
224
|
+
logger.warning("Sub-app directory for '%s' not found at %s", slug, app_dir)
|
|
225
|
+
is_admin = bool(user and getattr(user, "is_admin", False))
|
|
226
|
+
debug_mode = bool(self.main_app.config.get("DEBUG", False))
|
|
227
|
+
if is_admin or debug_mode:
|
|
228
|
+
msg = f"App directory for '{app_record.name}' was not found on the server filesystem."
|
|
229
|
+
else:
|
|
230
|
+
msg = f"The '{app_record.name}' application is currently unavailable."
|
|
214
231
|
return self._send_html_response(
|
|
215
232
|
start_response,
|
|
216
233
|
500,
|
|
217
|
-
"
|
|
218
|
-
|
|
234
|
+
"Application Unavailable",
|
|
235
|
+
msg,
|
|
219
236
|
link_url="/",
|
|
220
237
|
link_text="Return to Home",
|
|
221
238
|
)
|
|
222
239
|
|
|
223
240
|
try:
|
|
224
241
|
if slug not in self.sub_app_cache:
|
|
242
|
+
logger.info(
|
|
243
|
+
"Loading sub-app '%s' into WSGI cache (entrypoint: %s)",
|
|
244
|
+
slug,
|
|
245
|
+
app_record.entry_point,
|
|
246
|
+
)
|
|
225
247
|
sub_app_obj = load_wsgi_app_from_path(app_dir, app_record.entry_point)
|
|
226
248
|
# Get standard wsgi_app callable if it's a Flask instance
|
|
227
249
|
wsgi_callable = getattr(sub_app_obj, "wsgi_app", sub_app_obj)
|
|
@@ -233,11 +255,23 @@ class DynamicAppDispatcherMiddleware:
|
|
|
233
255
|
|
|
234
256
|
wsgi_callable = self.sub_app_cache[slug]
|
|
235
257
|
except Exception as e:
|
|
258
|
+
import logging
|
|
259
|
+
|
|
260
|
+
logging.getLogger("appmanager.dispatcher").exception(
|
|
261
|
+
f"Error loading sub-app '{app_record.slug}': {e}"
|
|
262
|
+
)
|
|
263
|
+
is_admin = bool(user and getattr(user, "is_admin", False))
|
|
264
|
+
debug_mode = bool(self.main_app.config.get("DEBUG", False))
|
|
265
|
+
if is_admin or debug_mode:
|
|
266
|
+
err_msg = f"Failed to load Flask sub-app '{app_record.name}': {str(e)}"
|
|
267
|
+
else:
|
|
268
|
+
err_msg = f"The '{app_record.name}' application encountered an error while starting. Please contact an administrator."
|
|
269
|
+
|
|
236
270
|
return self._send_html_response(
|
|
237
271
|
start_response,
|
|
238
272
|
500,
|
|
239
|
-
"
|
|
240
|
-
|
|
273
|
+
"Application Error",
|
|
274
|
+
err_msg,
|
|
241
275
|
link_url="/",
|
|
242
276
|
link_text="Return to Home",
|
|
243
277
|
)
|
|
@@ -116,20 +116,35 @@ def is_safe_repo_url(repo_url: str) -> bool:
|
|
|
116
116
|
def validate_entrypoint_path(app_dir: str, entry_point: str) -> Tuple[bool, str]:
|
|
117
117
|
"""
|
|
118
118
|
Ensures an entrypoint string does not escape the app directory via directory traversal.
|
|
119
|
+
Supports module names ('app'), dotted paths ('src.app'), and package paths ('appmanager').
|
|
119
120
|
"""
|
|
120
121
|
if ":" in entry_point:
|
|
121
122
|
module_name = entry_point.split(":", 1)[0]
|
|
122
123
|
else:
|
|
123
124
|
module_name = entry_point
|
|
124
125
|
|
|
125
|
-
# Disallow
|
|
126
|
-
if "
|
|
126
|
+
# Disallow parent references
|
|
127
|
+
if ".." in module_name:
|
|
127
128
|
return False, "Entrypoint module name cannot contain path separators or parent references."
|
|
128
129
|
|
|
129
|
-
module_file = os.path.join(app_dir, f"{module_name}.py")
|
|
130
|
-
abs_module = os.path.abspath(module_file)
|
|
131
130
|
abs_app_dir = os.path.abspath(app_dir)
|
|
132
131
|
|
|
132
|
+
# 1. Direct .py file path
|
|
133
|
+
if module_name.endswith(".py"):
|
|
134
|
+
cand_path = os.path.join(app_dir, module_name)
|
|
135
|
+
else:
|
|
136
|
+
cand_path = os.path.join(app_dir, f"{module_name}.py")
|
|
137
|
+
if not os.path.exists(cand_path):
|
|
138
|
+
# 2. Check dotted or nested path: e.g. src.app -> src/app.py or pkg/__init__.py
|
|
139
|
+
parts = module_name.replace("\\", "/").replace(".", "/").split("/")
|
|
140
|
+
cand_py = os.path.join(app_dir, *parts) + ".py"
|
|
141
|
+
cand_init = os.path.join(app_dir, *parts, "__init__.py")
|
|
142
|
+
if os.path.exists(cand_py):
|
|
143
|
+
cand_path = cand_py
|
|
144
|
+
elif os.path.exists(cand_init):
|
|
145
|
+
cand_path = cand_init
|
|
146
|
+
|
|
147
|
+
abs_module = os.path.abspath(cand_path)
|
|
133
148
|
if not abs_module.startswith(abs_app_dir):
|
|
134
149
|
return False, "Entrypoint file escapes application directory."
|
|
135
150
|
|
{appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/templates/admin/dashboard.html
RENAMED
|
@@ -207,7 +207,7 @@
|
|
|
207
207
|
<button type="submit" class="btn btn-ghost btn-sm" title="Set as default landing app">Default</button>
|
|
208
208
|
{% endif %}
|
|
209
209
|
</form>
|
|
210
|
-
<button type="button" class="btn btn-danger btn-sm" onclick="openConfirm('
|
|
210
|
+
<button type="button" class="btn btn-danger btn-sm" onclick="openConfirm('/admin/apps/{{ app.id }}/delete', '{{ app.slug }}', 'Uninstall {{ app.name }}?', 'Type <code>{{ app.slug }}</code> to confirm')">Uninstall</button>
|
|
211
211
|
</div>
|
|
212
212
|
</td>
|
|
213
213
|
</tr>
|
|
@@ -333,7 +333,7 @@
|
|
|
333
333
|
<td class="num">{{ role_counts.get(r.slug, 0) }}</td>
|
|
334
334
|
<td>
|
|
335
335
|
{% if not r.is_system %}
|
|
336
|
-
<button type="button" class="btn btn-danger btn-sm" onclick="openConfirm('
|
|
336
|
+
<button type="button" class="btn btn-danger btn-sm" onclick="openConfirm('/admin/roles/{{ r.id }}/delete', '{{ r.slug }}', 'Delete role {{ r.name }}?', 'Type <code>{{ r.slug }}</code> to confirm')">Delete</button>
|
|
337
337
|
{% else %}
|
|
338
338
|
<span class="text-faint" style="font-size:0.75rem;">Protected</span>
|
|
339
339
|
{% endif %}
|
|
@@ -621,18 +621,15 @@
|
|
|
621
621
|
}
|
|
622
622
|
|
|
623
623
|
// Type-to-confirm destructive action modal
|
|
624
|
-
var
|
|
625
|
-
function openConfirm(
|
|
626
|
-
|
|
627
|
-
document.getElementById('confirm-title').textContent = title;
|
|
628
|
-
document.getElementById('confirm-desc').innerHTML = desc;
|
|
624
|
+
var expectedConfirmText = '';
|
|
625
|
+
function openConfirm(actionUrl, expectedSlug, title, desc) {
|
|
626
|
+
expectedConfirmText = (expectedSlug || '').trim();
|
|
627
|
+
document.getElementById('confirm-title').textContent = title || 'Confirm Action';
|
|
628
|
+
document.getElementById('confirm-desc').innerHTML = desc || ('Type <code>' + expectedConfirmText + '</code> to confirm');
|
|
629
629
|
document.getElementById('confirm-input').value = '';
|
|
630
|
+
document.getElementById('confirm-input').placeholder = expectedConfirmText;
|
|
630
631
|
document.getElementById('confirm-submit').disabled = true;
|
|
631
|
-
document.getElementById('confirm-form').action =
|
|
632
|
-
// For roles, the action differs
|
|
633
|
-
if (slug.indexOf('role-') === 0) {
|
|
634
|
-
document.getElementById('confirm-form').action = '/admin/roles/' + slug.split('-').pop() + '/delete';
|
|
635
|
-
}
|
|
632
|
+
document.getElementById('confirm-form').action = actionUrl;
|
|
636
633
|
document.getElementById('confirm-modal').style.display = 'flex';
|
|
637
634
|
document.getElementById('confirm-input').focus();
|
|
638
635
|
}
|
|
@@ -640,7 +637,7 @@
|
|
|
640
637
|
document.getElementById('confirm-modal').style.display = 'none';
|
|
641
638
|
}
|
|
642
639
|
document.getElementById('confirm-input').addEventListener('input', function (e) {
|
|
643
|
-
document.getElementById('confirm-submit').disabled = (e.target.value.trim() !==
|
|
640
|
+
document.getElementById('confirm-submit').disabled = (e.target.value.trim() !== expectedConfirmText);
|
|
644
641
|
});
|
|
645
642
|
document.getElementById('confirm-modal').addEventListener('click', function (e) {
|
|
646
643
|
if (e.target === this) closeConfirm();
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "appmanager-server"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "High-performance multi-tenant WSGI portal, dynamic sub-app dispatcher, and extension framework for Python/Flask."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
@@ -116,3 +116,24 @@ def test_install_app_dependencies_no_reqs(tmp_path):
|
|
|
116
116
|
ok, msg = install_app_dependencies(str(empty_dir), venv_mode="singular")
|
|
117
117
|
assert ok is True
|
|
118
118
|
assert "nothing to install" in msg
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_install_app_dependencies_package_structure(tmp_path, monkeypatch):
|
|
122
|
+
pkg_dir = tmp_path / "pkg_app"
|
|
123
|
+
pkg_dir.mkdir()
|
|
124
|
+
(pkg_dir / "pyproject.toml").write_text("[project]\nname = 'pkg-app'\nversion = '0.1.0'\n")
|
|
125
|
+
|
|
126
|
+
# Mock subprocess.run to verify pip install -e is invoked
|
|
127
|
+
called_cmds = []
|
|
128
|
+
|
|
129
|
+
def mock_run(cmd, **kwargs):
|
|
130
|
+
called_cmds.append(cmd)
|
|
131
|
+
return type(
|
|
132
|
+
"Proc", (), {"returncode": 0, "stdout": "Successfully installed pkg-app", "stderr": ""}
|
|
133
|
+
)()
|
|
134
|
+
|
|
135
|
+
monkeypatch.setattr("subprocess.run", mock_run)
|
|
136
|
+
|
|
137
|
+
ok, msg = install_app_dependencies(str(pkg_dir), venv_mode="singular")
|
|
138
|
+
assert ok is True
|
|
139
|
+
assert any("-e" in c and str(pkg_dir) in c for c in called_cmds)
|
|
@@ -12,7 +12,7 @@ from appmanager.cli import main
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def test_package_exports():
|
|
15
|
-
assert __version__ == "0.
|
|
15
|
+
assert __version__ == "0.4.0"
|
|
16
16
|
assert callable(create_app)
|
|
17
17
|
assert callable(create_dispatchable_app)
|
|
18
18
|
assert DynamicAppDispatcherMiddleware is not None
|
|
@@ -25,7 +25,7 @@ def test_cli_version(capsys):
|
|
|
25
25
|
main(["--version"])
|
|
26
26
|
assert exc.value.code == 0
|
|
27
27
|
captured = capsys.readouterr()
|
|
28
|
-
assert
|
|
28
|
+
assert __version__ in captured.out or __version__ in captured.err
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
def test_cli_help(capsys):
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
# ==============================================================================
|
|
2
|
-
# AppManager Configuration
|
|
3
|
-
# Copy this file to .env and adjust the values as needed.
|
|
4
|
-
# ==============================================================================
|
|
5
|
-
|
|
6
|
-
# Core Application Security
|
|
7
|
-
# IMPORTANT: Generate strong random secrets for production!
|
|
8
|
-
SECRET_KEY=appmanager-change-this-to-a-secure-random-string-in-production-min-32-chars
|
|
9
|
-
JWT_SECRET=jwt-change-this-to-a-secure-random-string-in-production-min-32-chars
|
|
10
|
-
JWT_ACCESS_TOKEN_EXPIRES_DAYS=7
|
|
11
|
-
MAGIC_LINK_EXPIRES_MINUTES=15
|
|
12
|
-
|
|
13
|
-
# Server & Host URL
|
|
14
|
-
APP_BASE_URL=http://localhost:5000
|
|
15
|
-
PORT=5000
|
|
16
|
-
HOST=0.0.0.0
|
|
17
|
-
TEMPLATES_AUTO_RELOAD=true
|
|
18
|
-
SESSION_COOKIE_SAMESITE=Lax
|
|
19
|
-
SESSION_COOKIE_SECURE=false
|
|
20
|
-
|
|
21
|
-
# Database Configuration (SQLite default, PostgreSQL/MySQL supported)
|
|
22
|
-
# Examples:
|
|
23
|
-
# SQLite: sqlite:///instance/appmanager.db
|
|
24
|
-
# PostgreSQL: postgresql://user:password@localhost:5432/appmanager
|
|
25
|
-
# MySQL: mysql+pymysql://user:password@localhost:3306/appmanager
|
|
26
|
-
DATABASE_URL=sqlite:///instance/appmanager.db
|
|
27
|
-
|
|
28
|
-
# Storage Directories
|
|
29
|
-
# INSTALLED_APPS_DIR=./installed_apps
|
|
30
|
-
# TEMP_UPLOAD_DIR=./instance/uploads
|
|
31
|
-
|
|
32
|
-
# Google OAuth 2.0 (Optional - for Google Login)
|
|
33
|
-
GOOGLE_CLIENT_ID=
|
|
34
|
-
GOOGLE_CLIENT_SECRET=
|
|
35
|
-
GOOGLE_DISCOVERY_URL=https://accounts.google.com/.well-known/openid-configuration
|
|
36
|
-
|
|
37
|
-
# SMTP Email Configuration (Required for Magic Link email delivery in production)
|
|
38
|
-
SMTP_SERVER=
|
|
39
|
-
SMTP_PORT=587
|
|
40
|
-
SMTP_USER=
|
|
41
|
-
SMTP_PASSWORD=
|
|
42
|
-
MAIL_DEFAULT_SENDER=noreply@appmanager.local
|
|
43
|
-
|
|
44
|
-
# Developer Magic Login (Set to true to show one-click login button when SMTP is disabled)
|
|
45
|
-
ALLOW_DEV_MAGIC_LOGIN=false
|
|
46
|
-
|
|
47
|
-
# Role & Admin Provisioning
|
|
48
|
-
# FIRST_USER_IS_ADMIN: If true, the very first user who registers will be granted the admin role
|
|
49
|
-
FIRST_USER_IS_ADMIN=true
|
|
50
|
-
# ADMIN_EMAILS: Comma-separated list of emails that automatically receive admin role upon registration
|
|
51
|
-
ADMIN_EMAILS=
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
include LICENSE
|
|
2
|
-
include README.md
|
|
3
|
-
include CHANGELOG.md
|
|
4
|
-
include SECURITY.md
|
|
5
|
-
include requirements.txt
|
|
6
|
-
include .env.example
|
|
7
|
-
include appmanager/py.typed
|
|
8
|
-
recursive-include appmanager/templates *
|
|
9
|
-
recursive-include appmanager/static *
|
|
10
|
-
recursive-exclude * __pycache__
|
|
11
|
-
recursive-exclude * *.py[co]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/fonts/fonts.css
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/static/vendor/htmx/htmx.min.js
RENAMED
|
File without changes
|
{appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/templates/admin/_member_drawer.html
RENAMED
|
File without changes
|
{appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/templates/admin/_members_table.html
RENAMED
|
File without changes
|
{appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/templates/admin/app_detail.html
RENAMED
|
File without changes
|
|
File without changes
|
{appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager/templates/admin/permissions.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager_server.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager_server.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
{appmanager_server-0.3.0 → appmanager_server-0.4.0}/appmanager_server.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{appmanager_server-0.3.0 → appmanager_server-0.4.0}/tests/test_user_elevation_and_visibility.py
RENAMED
|
File without changes
|
|
File without changes
|