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.
Files changed (89) hide show
  1. appmanager/__init__.py +52 -0
  2. appmanager/admin/__init__.py +3 -0
  3. appmanager/admin/app_installer.py +739 -0
  4. appmanager/admin/members.py +95 -0
  5. appmanager/admin/registry.py +372 -0
  6. appmanager/admin/routes.py +900 -0
  7. appmanager/api/__init__.py +5 -0
  8. appmanager/api/routes.py +373 -0
  9. appmanager/app_config.py +101 -0
  10. appmanager/auth/__init__.py +3 -0
  11. appmanager/auth/oauth.py +77 -0
  12. appmanager/auth/routes.py +224 -0
  13. appmanager/auth/utils.py +175 -0
  14. appmanager/bridge.py +90 -0
  15. appmanager/cli.py +1171 -0
  16. appmanager/config.py +58 -0
  17. appmanager/database.py +120 -0
  18. appmanager/extension.py +198 -0
  19. appmanager/extensions.py +225 -0
  20. appmanager/health.py +133 -0
  21. appmanager/hooks.py +187 -0
  22. appmanager/middleware.py +256 -0
  23. appmanager/models.py +307 -0
  24. appmanager/py.typed +1 -0
  25. appmanager/sdk.py +30 -0
  26. appmanager/security.py +227 -0
  27. appmanager/security_scanner.py +582 -0
  28. appmanager/signals.py +21 -0
  29. appmanager/static/css/app.css +808 -0
  30. appmanager/static/vendor/fonts/fonts.css +288 -0
  31. appmanager/static/vendor/fonts/jetbrainsmono-0.woff2 +0 -0
  32. appmanager/static/vendor/fonts/jetbrainsmono-1.woff2 +0 -0
  33. appmanager/static/vendor/fonts/jetbrainsmono-10.woff2 +0 -0
  34. appmanager/static/vendor/fonts/jetbrainsmono-11.woff2 +0 -0
  35. appmanager/static/vendor/fonts/jetbrainsmono-12.woff2 +0 -0
  36. appmanager/static/vendor/fonts/jetbrainsmono-13.woff2 +0 -0
  37. appmanager/static/vendor/fonts/jetbrainsmono-14.woff2 +0 -0
  38. appmanager/static/vendor/fonts/jetbrainsmono-15.woff2 +0 -0
  39. appmanager/static/vendor/fonts/jetbrainsmono-16.woff2 +0 -0
  40. appmanager/static/vendor/fonts/jetbrainsmono-17.woff2 +0 -0
  41. appmanager/static/vendor/fonts/jetbrainsmono-18.woff2 +0 -0
  42. appmanager/static/vendor/fonts/jetbrainsmono-19.woff2 +0 -0
  43. appmanager/static/vendor/fonts/jetbrainsmono-2.woff2 +0 -0
  44. appmanager/static/vendor/fonts/jetbrainsmono-20.woff2 +0 -0
  45. appmanager/static/vendor/fonts/jetbrainsmono-21.woff2 +0 -0
  46. appmanager/static/vendor/fonts/jetbrainsmono-22.woff2 +0 -0
  47. appmanager/static/vendor/fonts/jetbrainsmono-23.woff2 +0 -0
  48. appmanager/static/vendor/fonts/jetbrainsmono-3.woff2 +0 -0
  49. appmanager/static/vendor/fonts/jetbrainsmono-4.woff2 +0 -0
  50. appmanager/static/vendor/fonts/jetbrainsmono-5.woff2 +0 -0
  51. appmanager/static/vendor/fonts/jetbrainsmono-6.woff2 +0 -0
  52. appmanager/static/vendor/fonts/jetbrainsmono-7.woff2 +0 -0
  53. appmanager/static/vendor/fonts/jetbrainsmono-8.woff2 +0 -0
  54. appmanager/static/vendor/fonts/jetbrainsmono-9.woff2 +0 -0
  55. appmanager/static/vendor/fonts/spacegrotesk-0.woff2 +0 -0
  56. appmanager/static/vendor/fonts/spacegrotesk-1.woff2 +0 -0
  57. appmanager/static/vendor/fonts/spacegrotesk-10.woff2 +0 -0
  58. appmanager/static/vendor/fonts/spacegrotesk-11.woff2 +0 -0
  59. appmanager/static/vendor/fonts/spacegrotesk-2.woff2 +0 -0
  60. appmanager/static/vendor/fonts/spacegrotesk-3.woff2 +0 -0
  61. appmanager/static/vendor/fonts/spacegrotesk-4.woff2 +0 -0
  62. appmanager/static/vendor/fonts/spacegrotesk-5.woff2 +0 -0
  63. appmanager/static/vendor/fonts/spacegrotesk-6.woff2 +0 -0
  64. appmanager/static/vendor/fonts/spacegrotesk-7.woff2 +0 -0
  65. appmanager/static/vendor/fonts/spacegrotesk-8.woff2 +0 -0
  66. appmanager/static/vendor/fonts/spacegrotesk-9.woff2 +0 -0
  67. appmanager/static/vendor/htmx/htmx.min.js +1 -0
  68. appmanager/templates/admin/_member_drawer.html +98 -0
  69. appmanager/templates/admin/_members_table.html +108 -0
  70. appmanager/templates/admin/app_detail.html +136 -0
  71. appmanager/templates/admin/dashboard.html +902 -0
  72. appmanager/templates/admin/flairs.html +90 -0
  73. appmanager/templates/admin/permissions.html +88 -0
  74. appmanager/templates/admin/roles.html +119 -0
  75. appmanager/templates/admin/users.html +156 -0
  76. appmanager/templates/auth/login.html +58 -0
  77. appmanager/templates/auth/profile.html +44 -0
  78. appmanager/templates/base.html +76 -0
  79. appmanager/templates/errors/401.html +19 -0
  80. appmanager/templates/errors/403.html +19 -0
  81. appmanager/templates/errors/404.html +18 -0
  82. appmanager/templates/errors/500.html +19 -0
  83. appmanager/templates/index.html +54 -0
  84. appmanager_server-0.2.0.dist-info/METADATA +320 -0
  85. appmanager_server-0.2.0.dist-info/RECORD +89 -0
  86. appmanager_server-0.2.0.dist-info/WHEEL +5 -0
  87. appmanager_server-0.2.0.dist-info/entry_points.txt +5 -0
  88. appmanager_server-0.2.0.dist-info/licenses/LICENSE +21 -0
  89. 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."