appmanager-server 0.2.0__py3-none-any.whl
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/__init__.py +52 -0
- appmanager/admin/__init__.py +3 -0
- appmanager/admin/app_installer.py +739 -0
- appmanager/admin/members.py +95 -0
- appmanager/admin/registry.py +372 -0
- appmanager/admin/routes.py +900 -0
- appmanager/api/__init__.py +5 -0
- appmanager/api/routes.py +373 -0
- appmanager/app_config.py +101 -0
- appmanager/auth/__init__.py +3 -0
- appmanager/auth/oauth.py +77 -0
- appmanager/auth/routes.py +224 -0
- appmanager/auth/utils.py +175 -0
- appmanager/bridge.py +90 -0
- appmanager/cli.py +1171 -0
- appmanager/config.py +58 -0
- appmanager/database.py +120 -0
- appmanager/extension.py +198 -0
- appmanager/extensions.py +225 -0
- appmanager/health.py +133 -0
- appmanager/hooks.py +187 -0
- appmanager/middleware.py +256 -0
- appmanager/models.py +307 -0
- appmanager/py.typed +1 -0
- appmanager/sdk.py +30 -0
- appmanager/security.py +227 -0
- appmanager/security_scanner.py +582 -0
- appmanager/signals.py +21 -0
- appmanager/static/css/app.css +808 -0
- appmanager/static/vendor/fonts/fonts.css +288 -0
- appmanager/static/vendor/fonts/jetbrainsmono-0.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-1.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-10.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-11.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-12.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-13.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-14.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-15.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-16.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-17.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-18.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-19.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-2.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-20.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-21.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-22.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-23.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-3.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-4.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-5.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-6.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-7.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-8.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-9.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-0.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-1.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-10.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-11.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-2.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-3.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-4.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-5.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-6.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-7.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-8.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-9.woff2 +0 -0
- appmanager/static/vendor/htmx/htmx.min.js +1 -0
- appmanager/templates/admin/_member_drawer.html +98 -0
- appmanager/templates/admin/_members_table.html +108 -0
- appmanager/templates/admin/app_detail.html +136 -0
- appmanager/templates/admin/dashboard.html +902 -0
- appmanager/templates/admin/flairs.html +90 -0
- appmanager/templates/admin/permissions.html +88 -0
- appmanager/templates/admin/roles.html +119 -0
- appmanager/templates/admin/users.html +156 -0
- appmanager/templates/auth/login.html +58 -0
- appmanager/templates/auth/profile.html +44 -0
- appmanager/templates/base.html +76 -0
- appmanager/templates/errors/401.html +19 -0
- appmanager/templates/errors/403.html +19 -0
- appmanager/templates/errors/404.html +18 -0
- appmanager/templates/errors/500.html +19 -0
- appmanager/templates/index.html +54 -0
- appmanager_server-0.2.0.dist-info/METADATA +320 -0
- appmanager_server-0.2.0.dist-info/RECORD +89 -0
- appmanager_server-0.2.0.dist-info/WHEEL +5 -0
- appmanager_server-0.2.0.dist-info/entry_points.txt +5 -0
- appmanager_server-0.2.0.dist-info/licenses/LICENSE +21 -0
- appmanager_server-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import importlib.util
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
import zipfile
|
|
11
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from flask import current_app
|
|
14
|
+
from werkzeug.utils import secure_filename
|
|
15
|
+
|
|
16
|
+
from appmanager.database import db
|
|
17
|
+
from appmanager.models import InstalledApp, User, UserAppPermission
|
|
18
|
+
from appmanager.security import (
|
|
19
|
+
extract_zip_safely,
|
|
20
|
+
is_safe_repo_url,
|
|
21
|
+
validate_entrypoint_path,
|
|
22
|
+
)
|
|
23
|
+
from appmanager.security_scanner import SecurityScanReport, run_security_scan
|
|
24
|
+
from appmanager.signals import subapp_installed, subapp_uninstalled
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def sanitize_slug(slug_text: str) -> str:
|
|
28
|
+
clean = secure_filename(slug_text.lower().replace(" ", "-"))
|
|
29
|
+
return clean or "sub-app"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_manifest(app_dir: str) -> Optional[Dict[str, Any]]:
|
|
33
|
+
"""
|
|
34
|
+
Parses manifest.json from app_dir if present.
|
|
35
|
+
If absent, attempts to discover a Python-defined AppManifest from entrypoints.
|
|
36
|
+
Returns dict or None.
|
|
37
|
+
"""
|
|
38
|
+
manifest_path = os.path.join(app_dir, "manifest.json")
|
|
39
|
+
if os.path.exists(manifest_path):
|
|
40
|
+
try:
|
|
41
|
+
with open(manifest_path, "r", encoding="utf-8") as f:
|
|
42
|
+
return json.load(f)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
print(f"[INSTALLER WARNING] Failed to parse manifest.json at {app_dir}: {e}")
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
# Fallback to Python-defined AppManifest discovery
|
|
48
|
+
try:
|
|
49
|
+
from appmanager_sdk.generator import load_manifest_from_module
|
|
50
|
+
|
|
51
|
+
for cand in ["app.py", "wsgi.py", "main.py", "run.py"]:
|
|
52
|
+
cand_path = os.path.join(app_dir, cand)
|
|
53
|
+
if os.path.exists(cand_path):
|
|
54
|
+
py_manifest, _ = load_manifest_from_module(cand_path, base_dir=app_dir)
|
|
55
|
+
if py_manifest:
|
|
56
|
+
return py_manifest.to_dict()
|
|
57
|
+
except Exception as e:
|
|
58
|
+
print(f"[INSTALLER WARNING] Python manifest auto-discovery error at {app_dir}: {e}")
|
|
59
|
+
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def normalize_and_flatten_app_dir(target_dir: str) -> None:
|
|
64
|
+
"""
|
|
65
|
+
Cleans OS metadata (__MACOSX, .DS_Store, Thumbs.db) and flattens single nested
|
|
66
|
+
subdirectories so manifest.json and entrypoint scripts reside at the root of target_dir.
|
|
67
|
+
"""
|
|
68
|
+
if not os.path.exists(target_dir) or not os.path.isdir(target_dir):
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
# 1. Purge OS junk
|
|
72
|
+
for junk in ["__MACOSX", ".DS_Store", "Thumbs.db", "__pycache__"]:
|
|
73
|
+
junk_path = os.path.join(target_dir, junk)
|
|
74
|
+
if os.path.exists(junk_path):
|
|
75
|
+
if os.path.isdir(junk_path):
|
|
76
|
+
shutil.rmtree(junk_path, ignore_errors=True)
|
|
77
|
+
else:
|
|
78
|
+
try:
|
|
79
|
+
os.remove(junk_path)
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
# 2. Check if root already contains manifest.json or standard entrypoints
|
|
84
|
+
has_root_manifest = os.path.exists(os.path.join(target_dir, "manifest.json"))
|
|
85
|
+
has_root_entrypoint = any(
|
|
86
|
+
os.path.exists(os.path.join(target_dir, cand))
|
|
87
|
+
for cand in ["app.py", "wsgi.py", "main.py", "run.py"]
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if has_root_manifest or has_root_entrypoint:
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
# 3. Locate nested directory containing app code
|
|
94
|
+
entries = [e for e in os.listdir(target_dir) if not e.startswith(".")]
|
|
95
|
+
candidate_dirs = [e for e in entries if os.path.isdir(os.path.join(target_dir, e))]
|
|
96
|
+
|
|
97
|
+
nested_dir = None
|
|
98
|
+
if len(candidate_dirs) == 1:
|
|
99
|
+
nested_dir = os.path.join(target_dir, candidate_dirs[0])
|
|
100
|
+
else:
|
|
101
|
+
for c in candidate_dirs:
|
|
102
|
+
cdir = os.path.join(target_dir, c)
|
|
103
|
+
if os.path.exists(os.path.join(cdir, "manifest.json")) or any(
|
|
104
|
+
os.path.exists(os.path.join(cdir, cand))
|
|
105
|
+
for cand in ["app.py", "wsgi.py", "main.py", "run.py"]
|
|
106
|
+
):
|
|
107
|
+
nested_dir = cdir
|
|
108
|
+
break
|
|
109
|
+
|
|
110
|
+
if nested_dir and os.path.isdir(nested_dir):
|
|
111
|
+
for item in os.listdir(nested_dir):
|
|
112
|
+
src = os.path.join(nested_dir, item)
|
|
113
|
+
dst = os.path.join(target_dir, item)
|
|
114
|
+
if os.path.exists(dst):
|
|
115
|
+
if os.path.isdir(dst):
|
|
116
|
+
shutil.rmtree(dst, ignore_errors=True)
|
|
117
|
+
else:
|
|
118
|
+
try:
|
|
119
|
+
os.remove(dst)
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
shutil.move(src, dst)
|
|
123
|
+
shutil.rmtree(nested_dir, ignore_errors=True)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def validate_subapp_package(path_or_zip: str) -> Tuple[bool, List[str], Dict[str, Any]]:
|
|
127
|
+
"""
|
|
128
|
+
Validates a sub-app package or directory against AppManager specifications.
|
|
129
|
+
Returns (is_valid, error_list, manifest_data).
|
|
130
|
+
"""
|
|
131
|
+
errors: List[str] = []
|
|
132
|
+
manifest: Dict[str, Any] = {}
|
|
133
|
+
|
|
134
|
+
temp_check_dir = None
|
|
135
|
+
|
|
136
|
+
if os.path.isfile(path_or_zip) and (
|
|
137
|
+
path_or_zip.endswith(".zip") or zipfile.is_zipfile(path_or_zip)
|
|
138
|
+
):
|
|
139
|
+
import tempfile
|
|
140
|
+
|
|
141
|
+
temp_check_dir = tempfile.mkdtemp(prefix="appmanager_val_")
|
|
142
|
+
try:
|
|
143
|
+
extract_zip_safely(path_or_zip, temp_check_dir)
|
|
144
|
+
target_dir = temp_check_dir
|
|
145
|
+
normalize_and_flatten_app_dir(target_dir)
|
|
146
|
+
except Exception as e:
|
|
147
|
+
errors.append(f"Failed to read ZIP archive: {str(e)}")
|
|
148
|
+
if temp_check_dir and os.path.exists(temp_check_dir):
|
|
149
|
+
shutil.rmtree(temp_check_dir, ignore_errors=True)
|
|
150
|
+
return False, errors, manifest
|
|
151
|
+
else:
|
|
152
|
+
target_dir = path_or_zip
|
|
153
|
+
|
|
154
|
+
if not os.path.exists(target_dir):
|
|
155
|
+
errors.append(f"Directory or file does not exist: {path_or_zip}")
|
|
156
|
+
return False, errors, manifest
|
|
157
|
+
|
|
158
|
+
# Check manifest.json or Python AppManifest
|
|
159
|
+
manifest_file = os.path.join(target_dir, "manifest.json")
|
|
160
|
+
if os.path.exists(manifest_file):
|
|
161
|
+
try:
|
|
162
|
+
with open(manifest_file, "r", encoding="utf-8") as f:
|
|
163
|
+
manifest = json.load(f)
|
|
164
|
+
if not isinstance(manifest, dict):
|
|
165
|
+
errors.append("'manifest.json' must contain a valid JSON object.")
|
|
166
|
+
else:
|
|
167
|
+
if "name" not in manifest or not manifest["name"]:
|
|
168
|
+
errors.append("Manifest 'name' field is required.")
|
|
169
|
+
if "slug" not in manifest or not manifest["slug"]:
|
|
170
|
+
errors.append("Manifest 'slug' field is required.")
|
|
171
|
+
except Exception as e:
|
|
172
|
+
errors.append(f"Invalid JSON syntax in 'manifest.json': {str(e)}")
|
|
173
|
+
else:
|
|
174
|
+
# Check if python-defined manifest exists
|
|
175
|
+
py_manifest = parse_manifest(target_dir)
|
|
176
|
+
if py_manifest:
|
|
177
|
+
manifest = py_manifest
|
|
178
|
+
if "name" not in manifest or not manifest["name"]:
|
|
179
|
+
errors.append("Manifest 'name' field is required.")
|
|
180
|
+
if "slug" not in manifest or not manifest["slug"]:
|
|
181
|
+
errors.append("Manifest 'slug' field is required.")
|
|
182
|
+
else:
|
|
183
|
+
errors.append("Missing required 'manifest.json' file or Python-defined AppManifest.")
|
|
184
|
+
|
|
185
|
+
# Check entry point file
|
|
186
|
+
entry_point = manifest.get("entry_point") if manifest else discover_entrypoint(target_dir)
|
|
187
|
+
is_safe_ep, ep_result = validate_entrypoint_path(target_dir, entry_point)
|
|
188
|
+
if not is_safe_ep:
|
|
189
|
+
errors.append(f"Security error: {ep_result}")
|
|
190
|
+
else:
|
|
191
|
+
entry_file = ep_result
|
|
192
|
+
if not os.path.exists(entry_file):
|
|
193
|
+
errors.append(f"Entrypoint module was not found at {entry_file}.")
|
|
194
|
+
else:
|
|
195
|
+
try:
|
|
196
|
+
with open(entry_file, "r", encoding="utf-8") as ef:
|
|
197
|
+
ast.parse(ef.read(), filename=entry_file)
|
|
198
|
+
except SyntaxError as syn_err:
|
|
199
|
+
errors.append(f"Syntax error in entrypoint file: {syn_err}")
|
|
200
|
+
except Exception as read_err:
|
|
201
|
+
errors.append(f"Could not read entrypoint file: {read_err}")
|
|
202
|
+
|
|
203
|
+
# Cleanup temp directory if created
|
|
204
|
+
if temp_check_dir and os.path.exists(temp_check_dir):
|
|
205
|
+
shutil.rmtree(temp_check_dir, ignore_errors=True)
|
|
206
|
+
|
|
207
|
+
is_valid = len(errors) == 0
|
|
208
|
+
return is_valid, errors, manifest
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def discover_entrypoint(app_dir: str) -> str:
|
|
212
|
+
"""
|
|
213
|
+
Search for common Flask entrypoint files (app.py, wsgi.py, main.py, run.py) or manifest.json.
|
|
214
|
+
Returns 'module_name:app_variable' string e.g. 'app:app'
|
|
215
|
+
"""
|
|
216
|
+
manifest = parse_manifest(app_dir)
|
|
217
|
+
if manifest and "entry_point" in manifest:
|
|
218
|
+
return manifest["entry_point"]
|
|
219
|
+
|
|
220
|
+
candidates = ["app.py", "wsgi.py", "main.py", "run.py"]
|
|
221
|
+
for candidate in candidates:
|
|
222
|
+
if os.path.exists(os.path.join(app_dir, candidate)):
|
|
223
|
+
module_name = candidate[:-3] # Strip .py
|
|
224
|
+
return f"{module_name}:app"
|
|
225
|
+
|
|
226
|
+
# Default fallback
|
|
227
|
+
return "app:app"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def install_dependencies(app_dir: str) -> None:
|
|
231
|
+
req_file = os.path.join(app_dir, "requirements.txt")
|
|
232
|
+
if os.path.exists(req_file):
|
|
233
|
+
try:
|
|
234
|
+
print(f"[INSTALLER] Installing dependencies for sub-app at {app_dir}...")
|
|
235
|
+
subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", req_file])
|
|
236
|
+
print("[INSTALLER] Sub-app dependencies installed successfully.")
|
|
237
|
+
except Exception as e:
|
|
238
|
+
print(f"[INSTALLER WARNING] Failed to install requirements.txt: {e}")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def load_wsgi_app_from_path(app_dir: str, entry_point: str = "app:app") -> Any:
|
|
242
|
+
"""
|
|
243
|
+
Dynamically imports a Flask/WSGI app instance from a specified directory and entrypoint.
|
|
244
|
+
Uses scoped module namespaces (appmanager.installed.<slug>) without sys.path pollution.
|
|
245
|
+
"""
|
|
246
|
+
if entry_point.count(":") != 1:
|
|
247
|
+
entry_point = "app:app"
|
|
248
|
+
|
|
249
|
+
module_name, app_var_name = entry_point.split(":")
|
|
250
|
+
is_safe, module_path_or_err = validate_entrypoint_path(app_dir, module_name)
|
|
251
|
+
if not is_safe:
|
|
252
|
+
raise ValueError(f"Security error: {module_path_or_err}")
|
|
253
|
+
|
|
254
|
+
module_path = module_path_or_err
|
|
255
|
+
if not os.path.exists(module_path):
|
|
256
|
+
raise FileNotFoundError(f"Entrypoint file '{module_name}.py' not found in {app_dir}")
|
|
257
|
+
|
|
258
|
+
# Unique scoped module spec name to avoid sys.path pollution
|
|
259
|
+
slug = os.path.basename(app_dir)
|
|
260
|
+
scoped_module_name = f"appmanager.installed.{slug}.{module_name}"
|
|
261
|
+
|
|
262
|
+
spec = importlib.util.spec_from_file_location(scoped_module_name, module_path)
|
|
263
|
+
if spec is None or spec.loader is None:
|
|
264
|
+
raise ImportError(f"Failed to load spec for {module_path}")
|
|
265
|
+
|
|
266
|
+
mod = importlib.util.module_from_spec(spec)
|
|
267
|
+
sys.modules[scoped_module_name] = mod
|
|
268
|
+
spec.loader.exec_module(mod)
|
|
269
|
+
|
|
270
|
+
# Check for Flask app instance or callable create_app factory
|
|
271
|
+
if hasattr(mod, app_var_name):
|
|
272
|
+
app_obj = getattr(mod, app_var_name)
|
|
273
|
+
if callable(app_obj) and not hasattr(app_obj, "wsgi_app"):
|
|
274
|
+
# Factory function e.g. create_app()
|
|
275
|
+
app_instance = app_obj()
|
|
276
|
+
else:
|
|
277
|
+
app_instance = app_obj
|
|
278
|
+
return app_instance
|
|
279
|
+
|
|
280
|
+
raise AttributeError(f"Module '{module_name}' in {app_dir} does not export '{app_var_name}'")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# In-memory staging storage: staging_id -> dict of staged info
|
|
284
|
+
_staged_installations: Dict[str, Dict[str, Any]] = {}
|
|
285
|
+
STAGING_EXPIRY_SECONDS = 1800 # 30 minutes
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _write_staging_metadata(staging_dir: str, meta: Dict[str, Any]) -> None:
|
|
289
|
+
"""Saves staging session metadata to disk inside the sandboxed staging directory."""
|
|
290
|
+
meta_path = os.path.join(staging_dir, "_staging_meta.json")
|
|
291
|
+
try:
|
|
292
|
+
# Don't serialize scan_report object directly to JSON if it's an object
|
|
293
|
+
serializable = dict(meta)
|
|
294
|
+
if "scan_report" in serializable and hasattr(serializable["scan_report"], "to_dict"):
|
|
295
|
+
serializable["scan_report"] = serializable["scan_report"].to_dict()
|
|
296
|
+
with open(meta_path, "w", encoding="utf-8") as f:
|
|
297
|
+
json.dump(serializable, f)
|
|
298
|
+
except Exception as e:
|
|
299
|
+
print(f"[INSTALLER WARNING] Could not write staging metadata to disk: {e}")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def cleanup_expired_staged_sessions(max_age: int = STAGING_EXPIRY_SECONDS) -> None:
|
|
303
|
+
now = time.time()
|
|
304
|
+
# 1. Clean in-memory
|
|
305
|
+
expired_ids = [
|
|
306
|
+
sid
|
|
307
|
+
for sid, data in list(_staged_installations.items())
|
|
308
|
+
if now - data.get("created_at", 0) > max_age
|
|
309
|
+
]
|
|
310
|
+
for sid in expired_ids:
|
|
311
|
+
cancel_staged_app(sid)
|
|
312
|
+
|
|
313
|
+
# 2. Clean orphaned disk directories
|
|
314
|
+
try:
|
|
315
|
+
import tempfile
|
|
316
|
+
|
|
317
|
+
temp_dir = tempfile.gettempdir()
|
|
318
|
+
for entry in os.listdir(temp_dir):
|
|
319
|
+
if entry.startswith("appmanager_stage_"):
|
|
320
|
+
entry_path = os.path.join(temp_dir, entry)
|
|
321
|
+
if os.path.isdir(entry_path):
|
|
322
|
+
mtime = os.path.getmtime(entry_path)
|
|
323
|
+
if now - mtime > max_age:
|
|
324
|
+
shutil.rmtree(entry_path, ignore_errors=True)
|
|
325
|
+
except Exception:
|
|
326
|
+
pass
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def get_staged_session(staging_id: str) -> Optional[Dict[str, Any]]:
|
|
330
|
+
if staging_id in _staged_installations:
|
|
331
|
+
data = _staged_installations[staging_id]
|
|
332
|
+
if time.time() - data.get("created_at", 0) <= STAGING_EXPIRY_SECONDS:
|
|
333
|
+
return data
|
|
334
|
+
|
|
335
|
+
# Disk fallback: look for staging directory on disk if server restarted
|
|
336
|
+
import tempfile
|
|
337
|
+
|
|
338
|
+
temp_dir = tempfile.gettempdir()
|
|
339
|
+
for entry in os.listdir(temp_dir):
|
|
340
|
+
if entry.startswith(f"appmanager_{staging_id}_") or entry == f"appmanager_{staging_id}":
|
|
341
|
+
entry_path = os.path.join(temp_dir, entry)
|
|
342
|
+
meta_path = os.path.join(entry_path, "_staging_meta.json")
|
|
343
|
+
if os.path.exists(meta_path):
|
|
344
|
+
try:
|
|
345
|
+
with open(meta_path, "r", encoding="utf-8") as f:
|
|
346
|
+
meta_data = json.load(f)
|
|
347
|
+
meta_data["staging_dir"] = entry_path
|
|
348
|
+
if time.time() - meta_data.get("created_at", 0) <= STAGING_EXPIRY_SECONDS:
|
|
349
|
+
_staged_installations[staging_id] = meta_data
|
|
350
|
+
return meta_data
|
|
351
|
+
except Exception:
|
|
352
|
+
pass
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def stage_git_repo(
|
|
357
|
+
repo_url: str,
|
|
358
|
+
name: str,
|
|
359
|
+
slug: Optional[str] = None,
|
|
360
|
+
entry_point: Optional[str] = None,
|
|
361
|
+
) -> Tuple[str, SecurityScanReport, Dict[str, Any]]:
|
|
362
|
+
"""
|
|
363
|
+
Clones a Git repository into a sandboxed staging directory, executes a static
|
|
364
|
+
security pre-check audit, and stores the staged session.
|
|
365
|
+
"""
|
|
366
|
+
if not is_safe_repo_url(repo_url):
|
|
367
|
+
raise ValueError(f"Invalid or unsafe git repository URL: '{repo_url}'")
|
|
368
|
+
|
|
369
|
+
cleanup_expired_staged_sessions()
|
|
370
|
+
|
|
371
|
+
import tempfile
|
|
372
|
+
|
|
373
|
+
staging_id = f"stage_{uuid.uuid4().hex[:12]}"
|
|
374
|
+
temp_stage_dir = tempfile.mkdtemp(prefix=f"appmanager_{staging_id}_")
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
try:
|
|
378
|
+
import git
|
|
379
|
+
|
|
380
|
+
git.Repo.clone_from(repo_url, temp_stage_dir, depth=1)
|
|
381
|
+
except Exception:
|
|
382
|
+
# Pass '--' separator before repo_url to prevent git command flag injection
|
|
383
|
+
res = subprocess.run(
|
|
384
|
+
["git", "clone", "--depth", "1", "--", repo_url, temp_stage_dir],
|
|
385
|
+
capture_output=True,
|
|
386
|
+
text=True,
|
|
387
|
+
)
|
|
388
|
+
if res.returncode != 0:
|
|
389
|
+
raise RuntimeError(
|
|
390
|
+
res.stderr.strip() or f"Failed to clone repository from '{repo_url}'."
|
|
391
|
+
)
|
|
392
|
+
except Exception as git_err:
|
|
393
|
+
if os.path.exists(temp_stage_dir):
|
|
394
|
+
shutil.rmtree(temp_stage_dir, ignore_errors=True)
|
|
395
|
+
raise ValueError(f"Failed to clone Git repository: {git_err}")
|
|
396
|
+
|
|
397
|
+
normalize_and_flatten_app_dir(temp_stage_dir)
|
|
398
|
+
|
|
399
|
+
# Validate that repository contains valid app files
|
|
400
|
+
manifest = parse_manifest(temp_stage_dir) or {}
|
|
401
|
+
resolved_entry_point = (
|
|
402
|
+
entry_point or manifest.get("entry_point") or discover_entrypoint(temp_stage_dir)
|
|
403
|
+
)
|
|
404
|
+
if not manifest and not any(
|
|
405
|
+
os.path.exists(os.path.join(temp_stage_dir, cand))
|
|
406
|
+
for cand in ["app.py", "wsgi.py", "main.py", "run.py"]
|
|
407
|
+
):
|
|
408
|
+
if os.path.exists(temp_stage_dir):
|
|
409
|
+
shutil.rmtree(temp_stage_dir, ignore_errors=True)
|
|
410
|
+
raise ValueError(
|
|
411
|
+
"Invalid sub-app repository: No valid entrypoint (app.py, wsgi.py) or manifest.json found in repository."
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
resolved_name = manifest.get("name") or name
|
|
415
|
+
resolved_slug = sanitize_slug(slug or manifest.get("slug") or resolved_name)
|
|
416
|
+
|
|
417
|
+
# Run static security pre-check
|
|
418
|
+
scan_report = run_security_scan(temp_stage_dir)
|
|
419
|
+
|
|
420
|
+
_staged_installations[staging_id] = {
|
|
421
|
+
"staging_id": staging_id,
|
|
422
|
+
"staging_dir": temp_stage_dir,
|
|
423
|
+
"source_type": "git",
|
|
424
|
+
"source_url": repo_url,
|
|
425
|
+
"name": resolved_name,
|
|
426
|
+
"slug": resolved_slug,
|
|
427
|
+
"entry_point": resolved_entry_point,
|
|
428
|
+
"manifest": manifest,
|
|
429
|
+
"scan_report": scan_report,
|
|
430
|
+
"created_at": time.time(),
|
|
431
|
+
}
|
|
432
|
+
_write_staging_metadata(temp_stage_dir, _staged_installations[staging_id])
|
|
433
|
+
|
|
434
|
+
return staging_id, scan_report, manifest
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def stage_zip_file(
|
|
438
|
+
zip_file_storage: Any,
|
|
439
|
+
name: str,
|
|
440
|
+
slug: Optional[str] = None,
|
|
441
|
+
entry_point: Optional[str] = None,
|
|
442
|
+
) -> Tuple[str, SecurityScanReport, Dict[str, Any]]:
|
|
443
|
+
"""
|
|
444
|
+
Safely extracts an uploaded zip archive into a sandboxed staging directory, executes
|
|
445
|
+
a static security pre-check audit, and stores the staged session.
|
|
446
|
+
"""
|
|
447
|
+
cleanup_expired_staged_sessions()
|
|
448
|
+
|
|
449
|
+
import tempfile
|
|
450
|
+
|
|
451
|
+
staging_id = f"stage_{uuid.uuid4().hex[:12]}"
|
|
452
|
+
temp_stage_dir = tempfile.mkdtemp(prefix=f"appmanager_{staging_id}_")
|
|
453
|
+
|
|
454
|
+
resolved_slug = sanitize_slug(slug or name)
|
|
455
|
+
filename = getattr(zip_file_storage, "filename", "upload.zip")
|
|
456
|
+
temp_zip_path = os.path.join(
|
|
457
|
+
current_app.config.get("TEMP_UPLOAD_DIR", tempfile.gettempdir()),
|
|
458
|
+
f"{resolved_slug}_{staging_id}_{secure_filename(filename)}",
|
|
459
|
+
)
|
|
460
|
+
os.makedirs(os.path.dirname(temp_zip_path), exist_ok=True)
|
|
461
|
+
zip_file_storage.save(temp_zip_path)
|
|
462
|
+
|
|
463
|
+
try:
|
|
464
|
+
extract_zip_safely(temp_zip_path, temp_stage_dir)
|
|
465
|
+
normalize_and_flatten_app_dir(temp_stage_dir)
|
|
466
|
+
except Exception as extract_err:
|
|
467
|
+
if os.path.exists(temp_stage_dir):
|
|
468
|
+
shutil.rmtree(temp_stage_dir, ignore_errors=True)
|
|
469
|
+
if isinstance(extract_err, zipfile.BadZipFile):
|
|
470
|
+
raise ValueError(f"Invalid or corrupted ZIP archive: {extract_err}")
|
|
471
|
+
raise ValueError(f"Failed to extract ZIP archive: {extract_err}")
|
|
472
|
+
finally:
|
|
473
|
+
if os.path.exists(temp_zip_path):
|
|
474
|
+
os.remove(temp_zip_path)
|
|
475
|
+
|
|
476
|
+
# Validate that archive contains valid app files
|
|
477
|
+
manifest = parse_manifest(temp_stage_dir) or {}
|
|
478
|
+
resolved_entry_point = (
|
|
479
|
+
entry_point or manifest.get("entry_point") or discover_entrypoint(temp_stage_dir)
|
|
480
|
+
)
|
|
481
|
+
if not manifest and not any(
|
|
482
|
+
os.path.exists(os.path.join(temp_stage_dir, cand))
|
|
483
|
+
for cand in ["app.py", "wsgi.py", "main.py", "run.py"]
|
|
484
|
+
):
|
|
485
|
+
if os.path.exists(temp_stage_dir):
|
|
486
|
+
shutil.rmtree(temp_stage_dir, ignore_errors=True)
|
|
487
|
+
raise ValueError(
|
|
488
|
+
"Invalid sub-app package: No valid entrypoint (app.py, wsgi.py) or manifest.json found in archive."
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
resolved_name = manifest.get("name") or name
|
|
492
|
+
resolved_slug = sanitize_slug(slug or manifest.get("slug") or resolved_name)
|
|
493
|
+
|
|
494
|
+
# Run static security pre-check
|
|
495
|
+
scan_report = run_security_scan(temp_stage_dir)
|
|
496
|
+
|
|
497
|
+
_staged_installations[staging_id] = {
|
|
498
|
+
"staging_id": staging_id,
|
|
499
|
+
"staging_dir": temp_stage_dir,
|
|
500
|
+
"source_type": "zip",
|
|
501
|
+
"source_url": getattr(zip_file_storage, "filename", "archive.zip"),
|
|
502
|
+
"name": resolved_name,
|
|
503
|
+
"slug": resolved_slug,
|
|
504
|
+
"entry_point": resolved_entry_point,
|
|
505
|
+
"manifest": manifest,
|
|
506
|
+
"scan_report": scan_report,
|
|
507
|
+
"created_at": time.time(),
|
|
508
|
+
}
|
|
509
|
+
_write_staging_metadata(temp_stage_dir, _staged_installations[staging_id])
|
|
510
|
+
|
|
511
|
+
return staging_id, scan_report, manifest
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def finalize_staged_installation(
|
|
515
|
+
staging_id: str,
|
|
516
|
+
name: Optional[str] = None,
|
|
517
|
+
slug: Optional[str] = None,
|
|
518
|
+
entry_point: Optional[str] = None,
|
|
519
|
+
) -> InstalledApp:
|
|
520
|
+
"""
|
|
521
|
+
Finalizes installation of a staged application: moves files into INSTALLED_APPS_DIR,
|
|
522
|
+
installs requirements, registers in database, configures settings and permissions.
|
|
523
|
+
"""
|
|
524
|
+
session = get_staged_session(staging_id)
|
|
525
|
+
if not session:
|
|
526
|
+
raise ValueError(
|
|
527
|
+
f"Staged session '{staging_id}' not found or has expired. Please run precheck again."
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
_staged_installations.pop(staging_id, None)
|
|
531
|
+
|
|
532
|
+
staging_dir = session["staging_dir"]
|
|
533
|
+
if not os.path.exists(staging_dir):
|
|
534
|
+
raise FileNotFoundError(f"Staging directory for '{staging_id}' no longer exists.")
|
|
535
|
+
|
|
536
|
+
final_slug = sanitize_slug(slug or session.get("slug") or session.get("name") or "sub-app")
|
|
537
|
+
target_dir = os.path.join(current_app.config["INSTALLED_APPS_DIR"], final_slug)
|
|
538
|
+
|
|
539
|
+
if os.path.exists(target_dir):
|
|
540
|
+
if os.path.exists(staging_dir):
|
|
541
|
+
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
542
|
+
raise ValueError(f"An app with slug '{final_slug}' is already installed.")
|
|
543
|
+
|
|
544
|
+
# Remove staging metadata file before moving into installed_apps
|
|
545
|
+
meta_path = os.path.join(staging_dir, "_staging_meta.json")
|
|
546
|
+
if os.path.exists(meta_path):
|
|
547
|
+
try:
|
|
548
|
+
os.remove(meta_path)
|
|
549
|
+
except Exception:
|
|
550
|
+
pass
|
|
551
|
+
|
|
552
|
+
# Move staged directory to final location
|
|
553
|
+
try:
|
|
554
|
+
os.makedirs(os.path.dirname(target_dir), exist_ok=True)
|
|
555
|
+
shutil.move(staging_dir, target_dir)
|
|
556
|
+
except Exception as move_err:
|
|
557
|
+
if os.path.exists(staging_dir):
|
|
558
|
+
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
559
|
+
raise RuntimeError(f"Failed to move staged app to target destination: {move_err}")
|
|
560
|
+
|
|
561
|
+
manifest = parse_manifest(target_dir) or session.get("manifest") or {}
|
|
562
|
+
final_name = name or session.get("name") or manifest.get("name") or final_slug
|
|
563
|
+
description = manifest.get("description") or session.get("manifest", {}).get("description")
|
|
564
|
+
final_entry_point = (
|
|
565
|
+
entry_point
|
|
566
|
+
or session.get("entry_point")
|
|
567
|
+
or manifest.get("entry_point")
|
|
568
|
+
or discover_entrypoint(target_dir)
|
|
569
|
+
)
|
|
570
|
+
app_type = manifest.get("app_type", "standalone")
|
|
571
|
+
target_app = manifest.get("target_app")
|
|
572
|
+
default_has_ui = False if app_type == "extension" else True
|
|
573
|
+
raw_has_ui = manifest.get("has_web_ui", manifest.get("has_ui", default_has_ui))
|
|
574
|
+
if isinstance(raw_has_ui, str):
|
|
575
|
+
has_web_ui = raw_has_ui.lower() in ("true", "1", "yes")
|
|
576
|
+
else:
|
|
577
|
+
has_web_ui = bool(raw_has_ui)
|
|
578
|
+
|
|
579
|
+
# Extract default settings if declared in manifest
|
|
580
|
+
initial_settings = {}
|
|
581
|
+
if "settings" in manifest and isinstance(manifest["settings"], dict):
|
|
582
|
+
for s_key, s_val in manifest["settings"].items():
|
|
583
|
+
if isinstance(s_val, dict):
|
|
584
|
+
initial_settings[s_key] = s_val.get("default", "")
|
|
585
|
+
else:
|
|
586
|
+
initial_settings[s_key] = s_val
|
|
587
|
+
|
|
588
|
+
install_dependencies(target_dir)
|
|
589
|
+
|
|
590
|
+
installed_app = InstalledApp(
|
|
591
|
+
name=final_name,
|
|
592
|
+
slug=final_slug,
|
|
593
|
+
description=description,
|
|
594
|
+
source_type=session.get("source_type", "upload"),
|
|
595
|
+
source_url=session.get("source_url", ""),
|
|
596
|
+
entry_point=final_entry_point,
|
|
597
|
+
app_type=app_type,
|
|
598
|
+
target_app=target_app,
|
|
599
|
+
has_web_ui=has_web_ui,
|
|
600
|
+
is_active=True,
|
|
601
|
+
)
|
|
602
|
+
if initial_settings:
|
|
603
|
+
installed_app.set_settings(initial_settings)
|
|
604
|
+
|
|
605
|
+
db.session.add(installed_app)
|
|
606
|
+
db.session.commit()
|
|
607
|
+
|
|
608
|
+
users = User.query.all()
|
|
609
|
+
for u in users:
|
|
610
|
+
perm = UserAppPermission(user_id=u.id, app_id=installed_app.id, can_access=True)
|
|
611
|
+
db.session.add(perm)
|
|
612
|
+
db.session.commit()
|
|
613
|
+
|
|
614
|
+
# Sync declared admin panels from the manifest
|
|
615
|
+
try:
|
|
616
|
+
from appmanager.admin.registry import sync_panels
|
|
617
|
+
|
|
618
|
+
sync_panels(installed_app, manifest)
|
|
619
|
+
except Exception:
|
|
620
|
+
pass
|
|
621
|
+
|
|
622
|
+
try:
|
|
623
|
+
subapp_installed.send(
|
|
624
|
+
None,
|
|
625
|
+
app_slug=final_slug,
|
|
626
|
+
source_type=session.get("source_type"),
|
|
627
|
+
app_id=installed_app.id,
|
|
628
|
+
)
|
|
629
|
+
except Exception:
|
|
630
|
+
pass
|
|
631
|
+
|
|
632
|
+
try:
|
|
633
|
+
from appmanager.hooks import trigger_hook
|
|
634
|
+
|
|
635
|
+
trigger_hook(
|
|
636
|
+
"on_app_installed", installed_app=installed_app, source_type=session.get("source_type")
|
|
637
|
+
)
|
|
638
|
+
except Exception:
|
|
639
|
+
pass
|
|
640
|
+
|
|
641
|
+
return installed_app
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def cancel_staged_app(staging_id: str) -> bool:
|
|
645
|
+
"""
|
|
646
|
+
Cancels a staged application and cleans up temporary files.
|
|
647
|
+
"""
|
|
648
|
+
session = _staged_installations.pop(staging_id, None)
|
|
649
|
+
if session and os.path.exists(session.get("staging_dir", "")):
|
|
650
|
+
shutil.rmtree(session["staging_dir"], ignore_errors=True)
|
|
651
|
+
return True
|
|
652
|
+
return False
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def install_from_git(
|
|
656
|
+
repo_url: str,
|
|
657
|
+
name: str,
|
|
658
|
+
slug: Optional[str] = None,
|
|
659
|
+
description: Optional[str] = None,
|
|
660
|
+
entry_point: Optional[str] = None,
|
|
661
|
+
) -> InstalledApp:
|
|
662
|
+
staging_id, scan_report, manifest = stage_git_repo(
|
|
663
|
+
repo_url=repo_url, name=name, slug=slug, entry_point=entry_point
|
|
664
|
+
)
|
|
665
|
+
return finalize_staged_installation(
|
|
666
|
+
staging_id=staging_id, name=name, slug=slug, entry_point=entry_point
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def install_from_zip(
|
|
671
|
+
zip_file_storage: Any,
|
|
672
|
+
name: str,
|
|
673
|
+
slug: Optional[str] = None,
|
|
674
|
+
description: Optional[str] = None,
|
|
675
|
+
entry_point: Optional[str] = None,
|
|
676
|
+
) -> InstalledApp:
|
|
677
|
+
staging_id, scan_report, manifest = stage_zip_file(
|
|
678
|
+
zip_file_storage=zip_file_storage, name=name, slug=slug, entry_point=entry_point
|
|
679
|
+
)
|
|
680
|
+
return finalize_staged_installation(
|
|
681
|
+
staging_id=staging_id, name=name, slug=slug, entry_point=entry_point
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def export_app_to_zip(slug: str, output_path: Optional[str] = None) -> str:
|
|
686
|
+
"""
|
|
687
|
+
Packages an installed sub-app directory into a clean distributable ZIP file.
|
|
688
|
+
"""
|
|
689
|
+
app_dir = os.path.join(current_app.config["INSTALLED_APPS_DIR"], slug)
|
|
690
|
+
if not os.path.exists(app_dir):
|
|
691
|
+
raise FileNotFoundError(f"Sub-app directory for '{slug}' not found.")
|
|
692
|
+
|
|
693
|
+
if not output_path:
|
|
694
|
+
output_path = f"{slug}.zip"
|
|
695
|
+
|
|
696
|
+
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zipf:
|
|
697
|
+
for root, dirs, files in os.walk(app_dir):
|
|
698
|
+
# Ignore __pycache__, .git, and build artifacts
|
|
699
|
+
dirs[:] = [d for d in dirs if d not in ("__pycache__", ".git", ".pytest_cache", "venv")]
|
|
700
|
+
for file in files:
|
|
701
|
+
if file.endswith((".pyc", ".pyo", ".DS_Store")):
|
|
702
|
+
continue
|
|
703
|
+
file_path = os.path.join(root, file)
|
|
704
|
+
arcname = os.path.relpath(file_path, app_dir)
|
|
705
|
+
zipf.write(file_path, arcname)
|
|
706
|
+
|
|
707
|
+
return output_path
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def uninstall_app(app_id: int) -> Tuple[bool, str]:
|
|
711
|
+
app_record = db.session.get(InstalledApp, app_id)
|
|
712
|
+
if not app_record:
|
|
713
|
+
return False, "App not found."
|
|
714
|
+
|
|
715
|
+
slug = app_record.slug
|
|
716
|
+
target_dir = os.path.join(current_app.config["INSTALLED_APPS_DIR"], slug)
|
|
717
|
+
if os.path.exists(target_dir):
|
|
718
|
+
shutil.rmtree(target_dir, ignore_errors=True)
|
|
719
|
+
|
|
720
|
+
# Invalidate cached WSGI instance if present in extension
|
|
721
|
+
if hasattr(current_app, "extensions") and "appmanager" in current_app.extensions:
|
|
722
|
+
current_app.extensions["appmanager"].clear_cache(slug=slug)
|
|
723
|
+
|
|
724
|
+
db.session.delete(app_record)
|
|
725
|
+
db.session.commit()
|
|
726
|
+
|
|
727
|
+
try:
|
|
728
|
+
subapp_uninstalled.send(None, app_slug=slug, app_id=app_id)
|
|
729
|
+
except Exception:
|
|
730
|
+
pass
|
|
731
|
+
|
|
732
|
+
try:
|
|
733
|
+
from appmanager.hooks import trigger_hook
|
|
734
|
+
|
|
735
|
+
trigger_hook("on_app_uninstalled", app_id=app_id, app_slug=slug)
|
|
736
|
+
except Exception:
|
|
737
|
+
pass
|
|
738
|
+
|
|
739
|
+
return True, "App uninstalled successfully."
|