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
appmanager/health.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from typing import List, Tuple
|
|
5
|
+
|
|
6
|
+
from werkzeug.test import Client
|
|
7
|
+
from werkzeug.wrappers import Response
|
|
8
|
+
|
|
9
|
+
from appmanager.admin.app_installer import load_wsgi_app_from_path, parse_manifest
|
|
10
|
+
from appmanager.database import db
|
|
11
|
+
from appmanager.models import AppHealthLog, InstalledApp
|
|
12
|
+
from appmanager.signals import health_check_completed, health_check_failed
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def check_app_health(app_record: InstalledApp) -> AppHealthLog:
|
|
16
|
+
"""
|
|
17
|
+
Evaluates health status of a single installed sub-app and logs the result.
|
|
18
|
+
Fires health_check_completed and health_check_failed signals.
|
|
19
|
+
"""
|
|
20
|
+
target_dir = (
|
|
21
|
+
os.path.join(db.app.config["INSTALLED_APPS_DIR"], app_record.slug)
|
|
22
|
+
if hasattr(db, "app")
|
|
23
|
+
else None
|
|
24
|
+
)
|
|
25
|
+
if not target_dir:
|
|
26
|
+
from flask import current_app
|
|
27
|
+
|
|
28
|
+
target_dir = os.path.join(current_app.config["INSTALLED_APPS_DIR"], app_record.slug)
|
|
29
|
+
|
|
30
|
+
if not os.path.exists(target_dir):
|
|
31
|
+
log = AppHealthLog(
|
|
32
|
+
app_id=app_record.id,
|
|
33
|
+
status="unhealthy",
|
|
34
|
+
response_time_ms=0.0,
|
|
35
|
+
details=json.dumps(
|
|
36
|
+
{"error": f"App directory for '{app_record.slug}' missing on disk."}
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
db.session.add(log)
|
|
40
|
+
db.session.commit()
|
|
41
|
+
try:
|
|
42
|
+
health_check_completed.send(
|
|
43
|
+
None, app_slug=app_record.slug, status="unhealthy", response_time_ms=0.0
|
|
44
|
+
)
|
|
45
|
+
health_check_failed.send(
|
|
46
|
+
None, app_slug=app_record.slug, status="unhealthy", details="App directory missing"
|
|
47
|
+
)
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
return log
|
|
51
|
+
|
|
52
|
+
manifest = parse_manifest(target_dir)
|
|
53
|
+
health_path = manifest.get("health_check_path", "/health") if manifest else "/health"
|
|
54
|
+
entry_point = (
|
|
55
|
+
manifest.get("entry_point", app_record.entry_point) if manifest else app_record.entry_point
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
start_time = time.time()
|
|
59
|
+
try:
|
|
60
|
+
sub_app_obj = load_wsgi_app_from_path(target_dir, entry_point)
|
|
61
|
+
wsgi_callable = getattr(sub_app_obj, "wsgi_app", sub_app_obj)
|
|
62
|
+
|
|
63
|
+
# Check if sub-app exports a direct get_health() function
|
|
64
|
+
if hasattr(sub_app_obj, "get_health") and callable(getattr(sub_app_obj, "get_health")):
|
|
65
|
+
res = sub_app_obj.get_health()
|
|
66
|
+
elapsed_ms = (time.time() - start_time) * 1000
|
|
67
|
+
status = res.get("status", "healthy") if isinstance(res, dict) else "healthy"
|
|
68
|
+
log = AppHealthLog(
|
|
69
|
+
app_id=app_record.id,
|
|
70
|
+
status=status,
|
|
71
|
+
response_time_ms=elapsed_ms,
|
|
72
|
+
details=json.dumps(res) if isinstance(res, dict) else str(res),
|
|
73
|
+
)
|
|
74
|
+
else:
|
|
75
|
+
# Issue internal WSGI request to health endpoint
|
|
76
|
+
client = Client(wsgi_callable, Response)
|
|
77
|
+
res = client.get(health_path)
|
|
78
|
+
elapsed_ms = (time.time() - start_time) * 1000
|
|
79
|
+
|
|
80
|
+
if res.status_code == 200:
|
|
81
|
+
status = "healthy"
|
|
82
|
+
try:
|
|
83
|
+
data = res.get_json() or res.get_data(as_text=True)
|
|
84
|
+
except Exception:
|
|
85
|
+
data = res.get_data(as_text=True)
|
|
86
|
+
details = json.dumps(data) if isinstance(data, (dict, list)) else str(data)
|
|
87
|
+
else:
|
|
88
|
+
status = "degraded" if res.status_code < 500 else "unhealthy"
|
|
89
|
+
details = json.dumps(
|
|
90
|
+
{"http_status": res.status_code, "body": res.get_data(as_text=True)}
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
log = AppHealthLog(
|
|
94
|
+
app_id=app_record.id, status=status, response_time_ms=elapsed_ms, details=details
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
except Exception as e:
|
|
98
|
+
elapsed_ms = (time.time() - start_time) * 1000
|
|
99
|
+
status = "unhealthy"
|
|
100
|
+
log = AppHealthLog(
|
|
101
|
+
app_id=app_record.id,
|
|
102
|
+
status="unhealthy",
|
|
103
|
+
response_time_ms=elapsed_ms,
|
|
104
|
+
details=json.dumps({"error": str(e)}),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
db.session.add(log)
|
|
108
|
+
db.session.commit()
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
health_check_completed.send(
|
|
112
|
+
None, app_slug=app_record.slug, status=log.status, response_time_ms=log.response_time_ms
|
|
113
|
+
)
|
|
114
|
+
if log.status in ("degraded", "unhealthy"):
|
|
115
|
+
health_check_failed.send(
|
|
116
|
+
None, app_slug=app_record.slug, status=log.status, details=log.details
|
|
117
|
+
)
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
return log
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def check_all_apps_health() -> List[Tuple[str, str, float]]:
|
|
125
|
+
"""
|
|
126
|
+
Runs health checks across all active registered sub-apps.
|
|
127
|
+
"""
|
|
128
|
+
apps = InstalledApp.query.filter_by(is_active=True).all()
|
|
129
|
+
results = []
|
|
130
|
+
for app_record in apps:
|
|
131
|
+
log = check_app_health(app_record)
|
|
132
|
+
results.append((app_record.slug, log.status, log.response_time_ms))
|
|
133
|
+
return results
|
appmanager/hooks.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AppManager Hook and UI Slot Registry System.
|
|
3
|
+
Provides a decoupled, pluggable architecture for UI mount points and lifecycle hooks.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from markupsafe import Markup
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HookRegistry:
|
|
12
|
+
"""
|
|
13
|
+
Central registry managing UI slots and lifecycle hooks for AppManager and its extensions.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
# slot_name -> list of dicts: {'callback': fn, 'priority': int, 'app_slug': str}
|
|
18
|
+
self._slots: Dict[str, List[Dict[str, Any]]] = {}
|
|
19
|
+
# hook_name -> list of dicts: {'callback': fn, 'priority': int, 'app_slug': str}
|
|
20
|
+
self._hooks: Dict[str, List[Dict[str, Any]]] = {}
|
|
21
|
+
|
|
22
|
+
def register_slot(
|
|
23
|
+
self,
|
|
24
|
+
slot_name: str,
|
|
25
|
+
callback: Callable[..., Any],
|
|
26
|
+
priority: int = 10,
|
|
27
|
+
app_slug: Optional[str] = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""
|
|
30
|
+
Registers a UI slot renderer.
|
|
31
|
+
Callback should return HTML string or Markup.
|
|
32
|
+
Lower priority value runs earlier.
|
|
33
|
+
"""
|
|
34
|
+
if slot_name not in self._slots:
|
|
35
|
+
self._slots[slot_name] = []
|
|
36
|
+
|
|
37
|
+
# Check if already registered to prevent duplicates
|
|
38
|
+
for item in self._slots[slot_name]:
|
|
39
|
+
if item["callback"] == callback and item.get("app_slug") == app_slug:
|
|
40
|
+
item["priority"] = priority
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
self._slots[slot_name].append(
|
|
44
|
+
{"callback": callback, "priority": priority, "app_slug": app_slug}
|
|
45
|
+
)
|
|
46
|
+
self._slots[slot_name].sort(key=lambda x: x["priority"])
|
|
47
|
+
|
|
48
|
+
def unregister_slot(self, slot_name: str, app_slug: Optional[str] = None) -> None:
|
|
49
|
+
"""
|
|
50
|
+
Unregisters all slot callbacks for a given app_slug or slot.
|
|
51
|
+
"""
|
|
52
|
+
if slot_name in self._slots:
|
|
53
|
+
if app_slug:
|
|
54
|
+
self._slots[slot_name] = [
|
|
55
|
+
x for x in self._slots[slot_name] if x.get("app_slug") != app_slug
|
|
56
|
+
]
|
|
57
|
+
else:
|
|
58
|
+
del self._slots[slot_name]
|
|
59
|
+
|
|
60
|
+
def render_slot(self, slot_name: str, *args: Any, **kwargs: Any) -> Markup:
|
|
61
|
+
"""
|
|
62
|
+
Executes all registered callbacks for the slot in priority order
|
|
63
|
+
and concatenates the rendered outputs safely as Jinja Markup.
|
|
64
|
+
"""
|
|
65
|
+
entries = self._slots.get(slot_name, [])
|
|
66
|
+
output_parts: List[str] = []
|
|
67
|
+
|
|
68
|
+
for entry in entries:
|
|
69
|
+
try:
|
|
70
|
+
result = entry["callback"](*args, **kwargs)
|
|
71
|
+
if result:
|
|
72
|
+
output_parts.append(str(result))
|
|
73
|
+
except Exception as e:
|
|
74
|
+
print(
|
|
75
|
+
f"[HOOK ERROR] Error rendering slot '{slot_name}' in app '{entry.get('app_slug')}': {e}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return Markup("".join(output_parts))
|
|
79
|
+
|
|
80
|
+
def register_hook(
|
|
81
|
+
self,
|
|
82
|
+
hook_name: str,
|
|
83
|
+
callback: Callable[..., Any],
|
|
84
|
+
priority: int = 10,
|
|
85
|
+
app_slug: Optional[str] = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""
|
|
88
|
+
Registers a lifecycle hook callback.
|
|
89
|
+
"""
|
|
90
|
+
if hook_name not in self._hooks:
|
|
91
|
+
self._hooks[hook_name] = []
|
|
92
|
+
|
|
93
|
+
for item in self._hooks[hook_name]:
|
|
94
|
+
if item["callback"] == callback and item.get("app_slug") == app_slug:
|
|
95
|
+
item["priority"] = priority
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
self._hooks[hook_name].append(
|
|
99
|
+
{"callback": callback, "priority": priority, "app_slug": app_slug}
|
|
100
|
+
)
|
|
101
|
+
self._hooks[hook_name].sort(key=lambda x: x["priority"])
|
|
102
|
+
|
|
103
|
+
def unregister_hook(self, hook_name: str, app_slug: Optional[str] = None) -> None:
|
|
104
|
+
"""
|
|
105
|
+
Unregisters lifecycle hook callbacks.
|
|
106
|
+
"""
|
|
107
|
+
if hook_name in self._hooks:
|
|
108
|
+
if app_slug:
|
|
109
|
+
self._hooks[hook_name] = [
|
|
110
|
+
x for x in self._hooks[hook_name] if x.get("app_slug") != app_slug
|
|
111
|
+
]
|
|
112
|
+
else:
|
|
113
|
+
del self._hooks[hook_name]
|
|
114
|
+
|
|
115
|
+
def trigger_hook(self, hook_name: str, *args: Any, **kwargs: Any) -> List[Any]:
|
|
116
|
+
"""
|
|
117
|
+
Triggers all registered callbacks for the lifecycle hook in priority order.
|
|
118
|
+
Returns a list of non-None return values from callbacks.
|
|
119
|
+
"""
|
|
120
|
+
entries = self._hooks.get(hook_name, [])
|
|
121
|
+
results: List[Any] = []
|
|
122
|
+
|
|
123
|
+
for entry in entries:
|
|
124
|
+
try:
|
|
125
|
+
res = entry["callback"](*args, **kwargs)
|
|
126
|
+
if res is not None:
|
|
127
|
+
results.append(res)
|
|
128
|
+
except Exception as e:
|
|
129
|
+
print(
|
|
130
|
+
f"[HOOK ERROR] Error triggering hook '{hook_name}' in app '{entry.get('app_slug')}': {e}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return results
|
|
134
|
+
|
|
135
|
+
def get_registered_slots(self) -> Dict[str, List[Dict[str, Any]]]:
|
|
136
|
+
"""
|
|
137
|
+
Returns snapshot of registered UI slots for inspection.
|
|
138
|
+
"""
|
|
139
|
+
return {
|
|
140
|
+
name: [
|
|
141
|
+
{"app_slug": item.get("app_slug"), "priority": item["priority"]} for item in items
|
|
142
|
+
]
|
|
143
|
+
for name, items in self._slots.items()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
def get_registered_hooks(self) -> Dict[str, List[Dict[str, Any]]]:
|
|
147
|
+
"""
|
|
148
|
+
Returns snapshot of registered lifecycle hooks for inspection.
|
|
149
|
+
"""
|
|
150
|
+
return {
|
|
151
|
+
name: [
|
|
152
|
+
{"app_slug": item.get("app_slug"), "priority": item["priority"]} for item in items
|
|
153
|
+
]
|
|
154
|
+
for name, items in self._hooks.items()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
def clear(self) -> None:
|
|
158
|
+
"""
|
|
159
|
+
Clears all registered slots and hooks.
|
|
160
|
+
"""
|
|
161
|
+
self._slots.clear()
|
|
162
|
+
self._hooks.clear()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# Global singleton instance
|
|
166
|
+
hooks = HookRegistry()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# Public helper functions
|
|
170
|
+
def register_slot(
|
|
171
|
+
slot_name: str, callback: Callable[..., Any], priority: int = 10, app_slug: Optional[str] = None
|
|
172
|
+
) -> None:
|
|
173
|
+
hooks.register_slot(slot_name, callback, priority, app_slug)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def render_slot(slot_name: str, *args: Any, **kwargs: Any) -> Markup:
|
|
177
|
+
return hooks.render_slot(slot_name, *args, **kwargs)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def register_hook(
|
|
181
|
+
hook_name: str, callback: Callable[..., Any], priority: int = 10, app_slug: Optional[str] = None
|
|
182
|
+
) -> None:
|
|
183
|
+
hooks.register_hook(hook_name, callback, priority, app_slug)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def trigger_hook(hook_name: str, *args: Any, **kwargs: Any) -> List[Any]:
|
|
187
|
+
return hooks.trigger_hook(hook_name, *args, **kwargs)
|
appmanager/middleware.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from typing import Any, Callable, Dict, Optional
|
|
4
|
+
|
|
5
|
+
import jwt
|
|
6
|
+
from flask import Flask
|
|
7
|
+
|
|
8
|
+
from appmanager.admin.app_installer import load_wsgi_app_from_path
|
|
9
|
+
from appmanager.database import db
|
|
10
|
+
from appmanager.models import InstalledApp, User, UserAppPermission
|
|
11
|
+
from appmanager.signals import subapp_reloaded
|
|
12
|
+
|
|
13
|
+
VALID_SLUG_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DynamicAppDispatcherMiddleware:
|
|
17
|
+
"""
|
|
18
|
+
WSGI middleware for dynamically dispatching requests to installed sub-apps under `/apps/<slug>/*`.
|
|
19
|
+
Provides namespace isolation, user header propagation, and in-memory callable caching.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, main_app: Flask) -> None:
|
|
23
|
+
self.main_app = main_app
|
|
24
|
+
self.sub_app_cache: Dict[str, Callable] = {}
|
|
25
|
+
|
|
26
|
+
def clear_cache(self, slug: Optional[str] = None) -> None:
|
|
27
|
+
"""
|
|
28
|
+
Clears the in-memory cache for a single sub-app or all sub-apps.
|
|
29
|
+
"""
|
|
30
|
+
if slug:
|
|
31
|
+
self.sub_app_cache.pop(slug, None)
|
|
32
|
+
else:
|
|
33
|
+
self.sub_app_cache.clear()
|
|
34
|
+
|
|
35
|
+
def _get_user_from_environ(
|
|
36
|
+
self, environ: Dict[str, Any], app_context_app: Flask
|
|
37
|
+
) -> Optional[User]:
|
|
38
|
+
"""
|
|
39
|
+
Extracts user and checks auth from WSGI request environ
|
|
40
|
+
"""
|
|
41
|
+
token = None
|
|
42
|
+
cookie_header = environ.get("HTTP_COOKIE", "")
|
|
43
|
+
if cookie_header:
|
|
44
|
+
from http.cookies import SimpleCookie
|
|
45
|
+
|
|
46
|
+
cookie = SimpleCookie()
|
|
47
|
+
try:
|
|
48
|
+
cookie.load(cookie_header)
|
|
49
|
+
if "appmanager_jwt" in cookie:
|
|
50
|
+
token = cookie["appmanager_jwt"].value
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
if not token:
|
|
55
|
+
auth_header = environ.get("HTTP_AUTHORIZATION", "")
|
|
56
|
+
if auth_header.startswith("Bearer "):
|
|
57
|
+
token = auth_header.split(" ")[1]
|
|
58
|
+
|
|
59
|
+
if not token:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
jwt_secret = app_context_app.config["JWT_SECRET"]
|
|
64
|
+
payload = jwt.decode(token, jwt_secret, algorithms=["HS256"])
|
|
65
|
+
user_id = payload.get("user_id")
|
|
66
|
+
user = db.session.get(User, user_id)
|
|
67
|
+
if user and user.is_active:
|
|
68
|
+
return user
|
|
69
|
+
except Exception:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def _send_html_response(
|
|
75
|
+
self,
|
|
76
|
+
start_response: Callable,
|
|
77
|
+
status_code: int,
|
|
78
|
+
title: str,
|
|
79
|
+
message: str,
|
|
80
|
+
link_url: Optional[str] = None,
|
|
81
|
+
link_text: Optional[str] = None,
|
|
82
|
+
):
|
|
83
|
+
status_names = {
|
|
84
|
+
400: "Bad Request",
|
|
85
|
+
401: "Unauthorized",
|
|
86
|
+
403: "Forbidden",
|
|
87
|
+
404: "Not Found",
|
|
88
|
+
500: "Internal Server Error",
|
|
89
|
+
}
|
|
90
|
+
status = f"{status_code} " + status_names.get(status_code, "Error")
|
|
91
|
+
headers = [
|
|
92
|
+
("Content-Type", "text/html; charset=utf-8"),
|
|
93
|
+
("X-Content-Type-Options", "nosniff"),
|
|
94
|
+
("X-Frame-Options", "SAMEORIGIN"),
|
|
95
|
+
]
|
|
96
|
+
code_color = (
|
|
97
|
+
"#6366f1"
|
|
98
|
+
if status_code == 401
|
|
99
|
+
else "#ef4444"
|
|
100
|
+
if status_code in (403, 404)
|
|
101
|
+
else "#f59e0b"
|
|
102
|
+
)
|
|
103
|
+
link_html = (
|
|
104
|
+
f'<p><a href="{link_url}" style="background-color:#6366f1;color:white;padding:10px 20px;text-decoration:none;border-radius:8px;font-weight:500;display:inline-block;transition:all 0.2s;">{link_text}</a></p>'
|
|
105
|
+
if link_url
|
|
106
|
+
else ""
|
|
107
|
+
)
|
|
108
|
+
html = f"""<!DOCTYPE html>
|
|
109
|
+
<html>
|
|
110
|
+
<head>
|
|
111
|
+
<title>{title} - AppManager</title>
|
|
112
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
113
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
114
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
115
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
|
116
|
+
<style>
|
|
117
|
+
body {{ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; background: #0f172a; color: #f8fafc; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; padding: 20px; box-sizing: border-box; }}
|
|
118
|
+
.card {{ background: #1e293b; padding: 40px; border-radius: 16px; max-width: 480px; width: 100%; text-align: center; border: 1px solid #334155; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.5); }}
|
|
119
|
+
.code {{ font-size: 64px; font-weight: 800; color: {code_color}; margin-bottom: 8px; line-height: 1; }}
|
|
120
|
+
h1 {{ color: #f8fafc; font-size: 22px; font-weight: 700; margin-top: 0; margin-bottom: 12px; }}
|
|
121
|
+
p {{ color: #94a3b8; line-height: 1.6; font-size: 14px; margin-bottom: 24px; }}
|
|
122
|
+
</style>
|
|
123
|
+
</head>
|
|
124
|
+
<body>
|
|
125
|
+
<div class="card">
|
|
126
|
+
<div class="code">{status_code}</div>
|
|
127
|
+
<h1>{title}</h1>
|
|
128
|
+
<p>{message}</p>
|
|
129
|
+
{link_html}
|
|
130
|
+
</div>
|
|
131
|
+
</body>
|
|
132
|
+
</html>"""
|
|
133
|
+
start_response(status, headers)
|
|
134
|
+
return [html.encode("utf-8")]
|
|
135
|
+
|
|
136
|
+
def __call__(self, environ: Dict[str, Any], start_response: Callable):
|
|
137
|
+
# 1. Strip any client-supplied spoofed AppManager internal headers from incoming environ
|
|
138
|
+
for header_key in [k for k in environ.keys() if k.startswith("HTTP_X_APPMANAGER_")]:
|
|
139
|
+
del environ[header_key]
|
|
140
|
+
|
|
141
|
+
path_info = environ.get("PATH_INFO", "")
|
|
142
|
+
|
|
143
|
+
# Check if the request is targeting an installed sub-app (/apps/<slug>/...)
|
|
144
|
+
if path_info.startswith("/apps/"):
|
|
145
|
+
parts = path_info.strip("/").split("/")
|
|
146
|
+
if len(parts) >= 2:
|
|
147
|
+
slug = parts[1]
|
|
148
|
+
|
|
149
|
+
# Validate slug to prevent path traversal
|
|
150
|
+
if not VALID_SLUG_PATTERN.match(slug):
|
|
151
|
+
return self._send_html_response(
|
|
152
|
+
start_response,
|
|
153
|
+
400,
|
|
154
|
+
"Invalid App Identifier",
|
|
155
|
+
"The requested app path contains invalid characters.",
|
|
156
|
+
link_url="/",
|
|
157
|
+
link_text="Return to Home",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Use main app context to query database
|
|
161
|
+
with self.main_app.app_context():
|
|
162
|
+
app_record = InstalledApp.query.filter_by(slug=slug, is_active=True).first()
|
|
163
|
+
if not app_record:
|
|
164
|
+
return self._send_html_response(
|
|
165
|
+
start_response,
|
|
166
|
+
404,
|
|
167
|
+
"App Not Found",
|
|
168
|
+
f"No active Flask app is installed at path '/apps/{slug}'.",
|
|
169
|
+
link_url="/",
|
|
170
|
+
link_text="Return to Home",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# 1. Authenticate User & Check Permission
|
|
174
|
+
user = self._get_user_from_environ(environ, self.main_app)
|
|
175
|
+
if app_record.requires_auth:
|
|
176
|
+
if not user:
|
|
177
|
+
redirect_path = f"/auth/login?next=/apps/{slug}"
|
|
178
|
+
return self._send_html_response(
|
|
179
|
+
start_response,
|
|
180
|
+
401,
|
|
181
|
+
"Authentication Required",
|
|
182
|
+
f"You must be logged into AppManager to access '{app_record.name}'.",
|
|
183
|
+
link_url=redirect_path,
|
|
184
|
+
link_text="Log In to Continue",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if not user.is_admin():
|
|
188
|
+
perm = UserAppPermission.query.filter_by(
|
|
189
|
+
user_id=user.id, app_id=app_record.id
|
|
190
|
+
).first()
|
|
191
|
+
if not perm or not perm.can_access:
|
|
192
|
+
return self._send_html_response(
|
|
193
|
+
start_response,
|
|
194
|
+
403,
|
|
195
|
+
"Access Denied",
|
|
196
|
+
f"You do not have permission to access the '{app_record.name}' application. Contact your administrator.",
|
|
197
|
+
link_url="/auth/profile",
|
|
198
|
+
link_text="Go to My Profile",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# 2. Inject Verified User & Sub-App Context Headers (Guaranteed non-spoofed)
|
|
202
|
+
if user:
|
|
203
|
+
environ["HTTP_X_APPMANAGER_USER_ID"] = str(user.id)
|
|
204
|
+
environ["HTTP_X_APPMANAGER_USER_EMAIL"] = user.email
|
|
205
|
+
environ["HTTP_X_APPMANAGER_USER_ROLE"] = user.role
|
|
206
|
+
environ["HTTP_X_APPMANAGER_SUBAPP_SLUG"] = slug
|
|
207
|
+
environ["HTTP_X_FORWARDED_PREFIX"] = f"/apps/{slug}"
|
|
208
|
+
|
|
209
|
+
# 3. Load or Retrieve Sub-App WSGI Instance
|
|
210
|
+
app_dir = os.path.join(
|
|
211
|
+
self.main_app.config["INSTALLED_APPS_DIR"], app_record.slug
|
|
212
|
+
)
|
|
213
|
+
if not os.path.exists(app_dir):
|
|
214
|
+
return self._send_html_response(
|
|
215
|
+
start_response,
|
|
216
|
+
500,
|
|
217
|
+
"Installation Error",
|
|
218
|
+
f"App directory for '{app_record.name}' was not found on the server filesystem.",
|
|
219
|
+
link_url="/",
|
|
220
|
+
link_text="Return to Home",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
if slug not in self.sub_app_cache:
|
|
225
|
+
sub_app_obj = load_wsgi_app_from_path(app_dir, app_record.entry_point)
|
|
226
|
+
# Get standard wsgi_app callable if it's a Flask instance
|
|
227
|
+
wsgi_callable = getattr(sub_app_obj, "wsgi_app", sub_app_obj)
|
|
228
|
+
self.sub_app_cache[slug] = wsgi_callable
|
|
229
|
+
try:
|
|
230
|
+
subapp_reloaded.send(self, slug=slug)
|
|
231
|
+
except Exception:
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
wsgi_callable = self.sub_app_cache[slug]
|
|
235
|
+
except Exception as e:
|
|
236
|
+
return self._send_html_response(
|
|
237
|
+
start_response,
|
|
238
|
+
500,
|
|
239
|
+
"App Load Error",
|
|
240
|
+
f"Failed to load Flask sub-app '{app_record.name}': {str(e)}",
|
|
241
|
+
link_url="/",
|
|
242
|
+
link_text="Return to Home",
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Rewrite SCRIPT_NAME and PATH_INFO for WSGI sub-app routing
|
|
246
|
+
prefix = f"/apps/{slug}"
|
|
247
|
+
environ["SCRIPT_NAME"] = environ.get("SCRIPT_NAME", "") + prefix
|
|
248
|
+
new_path_info = path_info[len(prefix) :]
|
|
249
|
+
if not new_path_info:
|
|
250
|
+
new_path_info = "/"
|
|
251
|
+
environ["PATH_INFO"] = new_path_info
|
|
252
|
+
|
|
253
|
+
return wsgi_callable(environ, start_response)
|
|
254
|
+
|
|
255
|
+
# Fall through to master AppManager Flask app
|
|
256
|
+
return self.main_app(environ, start_response)
|