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/models.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
|
|
3
|
+
from appmanager.database import db
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Role(db.Model):
|
|
7
|
+
__tablename__ = "roles"
|
|
8
|
+
|
|
9
|
+
id = db.Column(db.Integer, primary_key=True)
|
|
10
|
+
name = db.Column(db.String(100), unique=True, nullable=False)
|
|
11
|
+
slug = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
|
12
|
+
description = db.Column(db.String(255), nullable=True)
|
|
13
|
+
is_system = db.Column(db.Boolean, default=False, nullable=False)
|
|
14
|
+
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
|
15
|
+
|
|
16
|
+
def to_dict(self):
|
|
17
|
+
return {
|
|
18
|
+
"id": self.id,
|
|
19
|
+
"name": self.name,
|
|
20
|
+
"slug": self.slug,
|
|
21
|
+
"description": self.description or "",
|
|
22
|
+
"is_system": self.is_system,
|
|
23
|
+
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class User(db.Model):
|
|
28
|
+
__tablename__ = "users"
|
|
29
|
+
|
|
30
|
+
id = db.Column(db.Integer, primary_key=True)
|
|
31
|
+
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
|
32
|
+
name = db.Column(db.String(255), nullable=True)
|
|
33
|
+
google_id = db.Column(db.String(255), unique=True, nullable=True)
|
|
34
|
+
role = db.Column(db.String(50), nullable=False, default="user") # 'admin' or 'user'
|
|
35
|
+
is_active = db.Column(db.Boolean, default=True)
|
|
36
|
+
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
|
37
|
+
last_login_at = db.Column(db.DateTime, nullable=True)
|
|
38
|
+
last_active_at = db.Column(db.DateTime, nullable=True)
|
|
39
|
+
login_count = db.Column(db.Integer, default=0, nullable=False)
|
|
40
|
+
last_ip = db.Column(db.String(100), nullable=True)
|
|
41
|
+
|
|
42
|
+
permissions = db.relationship("UserAppPermission", backref="user", cascade="all, delete-orphan")
|
|
43
|
+
|
|
44
|
+
def is_admin(self):
|
|
45
|
+
return self.role == "admin"
|
|
46
|
+
|
|
47
|
+
def is_online(self):
|
|
48
|
+
if not self.last_active_at:
|
|
49
|
+
return False
|
|
50
|
+
dt = self.last_active_at
|
|
51
|
+
if dt.tzinfo is None:
|
|
52
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
53
|
+
diff = (datetime.now(timezone.utc) - dt).total_seconds()
|
|
54
|
+
return diff < 300 # 5 minutes
|
|
55
|
+
|
|
56
|
+
def to_dict(self):
|
|
57
|
+
return {
|
|
58
|
+
"id": self.id,
|
|
59
|
+
"email": self.email,
|
|
60
|
+
"name": self.name or self.email,
|
|
61
|
+
"role": self.role,
|
|
62
|
+
"is_active": self.is_active,
|
|
63
|
+
"is_online": self.is_online(),
|
|
64
|
+
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
65
|
+
"last_login_at": self.last_login_at.isoformat() if self.last_login_at else None,
|
|
66
|
+
"last_active_at": self.last_active_at.isoformat() if self.last_active_at else None,
|
|
67
|
+
"login_count": self.login_count,
|
|
68
|
+
"last_ip": self.last_ip,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class InstalledApp(db.Model):
|
|
73
|
+
__tablename__ = "installed_apps"
|
|
74
|
+
|
|
75
|
+
id = db.Column(db.Integer, primary_key=True)
|
|
76
|
+
name = db.Column(db.String(255), nullable=False)
|
|
77
|
+
slug = db.Column(
|
|
78
|
+
db.String(100), unique=True, nullable=False, index=True
|
|
79
|
+
) # URL path under /apps/<slug>
|
|
80
|
+
description = db.Column(db.Text, nullable=True)
|
|
81
|
+
source_type = db.Column(db.String(50), nullable=False) # 'git' or 'zip'
|
|
82
|
+
source_url = db.Column(db.String(500), nullable=True) # Git repo URL or original ZIP filename
|
|
83
|
+
entry_point = db.Column(
|
|
84
|
+
db.String(255), nullable=False, default="app:app"
|
|
85
|
+
) # e.g., app:app or main:create_app()
|
|
86
|
+
installed_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
|
87
|
+
is_active = db.Column(db.Boolean, default=True)
|
|
88
|
+
requires_auth = db.Column(db.Boolean, default=True, nullable=False)
|
|
89
|
+
is_default = db.Column(db.Boolean, default=False, nullable=False)
|
|
90
|
+
app_type = db.Column(
|
|
91
|
+
db.String(50), default="standalone", nullable=False
|
|
92
|
+
) # 'standalone' or 'extension'
|
|
93
|
+
target_app = db.Column(
|
|
94
|
+
db.String(100), nullable=True
|
|
95
|
+
) # Target app slug if app_type is 'extension'
|
|
96
|
+
has_web_ui = db.Column(db.Boolean, default=True, nullable=False)
|
|
97
|
+
settings_json = db.Column(db.Text, nullable=True)
|
|
98
|
+
|
|
99
|
+
permissions = db.relationship("UserAppPermission", backref="app", cascade="all, delete-orphan")
|
|
100
|
+
|
|
101
|
+
def get_settings(self):
|
|
102
|
+
import json
|
|
103
|
+
|
|
104
|
+
if self.settings_json:
|
|
105
|
+
try:
|
|
106
|
+
return json.loads(self.settings_json)
|
|
107
|
+
except Exception:
|
|
108
|
+
return {}
|
|
109
|
+
return {}
|
|
110
|
+
|
|
111
|
+
def set_settings(self, settings_dict):
|
|
112
|
+
import json
|
|
113
|
+
|
|
114
|
+
self.settings_json = json.dumps(settings_dict) if settings_dict is not None else None
|
|
115
|
+
|
|
116
|
+
def to_dict(self):
|
|
117
|
+
return {
|
|
118
|
+
"id": self.id,
|
|
119
|
+
"name": self.name,
|
|
120
|
+
"slug": self.slug,
|
|
121
|
+
"description": self.description or "",
|
|
122
|
+
"source_type": self.source_type,
|
|
123
|
+
"source_url": self.source_url,
|
|
124
|
+
"entry_point": self.entry_point,
|
|
125
|
+
"installed_at": self.installed_at.isoformat() if self.installed_at else None,
|
|
126
|
+
"is_active": self.is_active,
|
|
127
|
+
"requires_auth": self.requires_auth,
|
|
128
|
+
"is_default": self.is_default,
|
|
129
|
+
"app_type": self.app_type,
|
|
130
|
+
"target_app": self.target_app,
|
|
131
|
+
"has_web_ui": self.has_web_ui,
|
|
132
|
+
"settings": self.get_settings(),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class UserAppPermission(db.Model):
|
|
137
|
+
__tablename__ = "user_app_permissions"
|
|
138
|
+
|
|
139
|
+
id = db.Column(db.Integer, primary_key=True)
|
|
140
|
+
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
|
|
141
|
+
app_id = db.Column(db.Integer, db.ForeignKey("installed_apps.id"), nullable=False)
|
|
142
|
+
can_access = db.Column(db.Boolean, default=True)
|
|
143
|
+
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
|
144
|
+
|
|
145
|
+
__table_args__ = (db.UniqueConstraint("user_id", "app_id", name="_user_app_uc"),)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class MagicLinkToken(db.Model):
|
|
149
|
+
__tablename__ = "magic_link_tokens"
|
|
150
|
+
|
|
151
|
+
id = db.Column(db.Integer, primary_key=True)
|
|
152
|
+
email = db.Column(db.String(255), nullable=False, index=True)
|
|
153
|
+
token = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
|
154
|
+
expires_at = db.Column(db.DateTime, nullable=False)
|
|
155
|
+
used = db.Column(db.Boolean, default=False)
|
|
156
|
+
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class AppHealthLog(db.Model):
|
|
160
|
+
__tablename__ = "app_health_logs"
|
|
161
|
+
|
|
162
|
+
id = db.Column(db.Integer, primary_key=True)
|
|
163
|
+
app_id = db.Column(db.Integer, db.ForeignKey("installed_apps.id"), nullable=False)
|
|
164
|
+
status = db.Column(
|
|
165
|
+
db.String(50), nullable=False, default="healthy"
|
|
166
|
+
) # healthy, degraded, unhealthy
|
|
167
|
+
response_time_ms = db.Column(db.Float, nullable=True)
|
|
168
|
+
details = db.Column(db.Text, nullable=True)
|
|
169
|
+
checked_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
|
170
|
+
|
|
171
|
+
app = db.relationship(
|
|
172
|
+
"InstalledApp", backref=db.backref("health_logs", cascade="all, delete-orphan")
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class AppTelemetryLog(db.Model):
|
|
177
|
+
__tablename__ = "app_telemetry_logs"
|
|
178
|
+
|
|
179
|
+
id = db.Column(db.Integer, primary_key=True)
|
|
180
|
+
app_slug = db.Column(db.String(100), nullable=False, index=True)
|
|
181
|
+
event_type = db.Column(db.String(100), nullable=False) # event, metric, error
|
|
182
|
+
payload_json = db.Column(db.Text, nullable=True)
|
|
183
|
+
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class AppExtensionData(db.Model):
|
|
187
|
+
__tablename__ = "app_extension_data"
|
|
188
|
+
|
|
189
|
+
id = db.Column(db.Integer, primary_key=True)
|
|
190
|
+
extension_slug = db.Column(db.String(100), nullable=False, index=True)
|
|
191
|
+
entity_type = db.Column(db.String(50), nullable=False, index=True) # e.g., 'user'
|
|
192
|
+
entity_id = db.Column(db.Integer, nullable=False, index=True)
|
|
193
|
+
data_json = db.Column(db.Text, nullable=True)
|
|
194
|
+
updated_at = db.Column(
|
|
195
|
+
db.DateTime,
|
|
196
|
+
default=lambda: datetime.now(timezone.utc),
|
|
197
|
+
onupdate=lambda: datetime.now(timezone.utc),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
__table_args__ = (
|
|
201
|
+
db.UniqueConstraint("extension_slug", "entity_type", "entity_id", name="_ext_entity_uc"),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class AppConfig(db.Model):
|
|
206
|
+
"""
|
|
207
|
+
Typed per-app configuration store. Replaces the freeform `settings_json`
|
|
208
|
+
blob as the primary settings interface. Each setting is a row — queryable,
|
|
209
|
+
auditable, and safe for concurrent updates (no read-modify-write on a blob).
|
|
210
|
+
|
|
211
|
+
Extensions declare their config schema on install; the framework seeds
|
|
212
|
+
default rows and renders a generated form (or the extension provides a
|
|
213
|
+
custom admin panel).
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
__tablename__ = "app_configs"
|
|
217
|
+
|
|
218
|
+
id = db.Column(db.Integer, primary_key=True)
|
|
219
|
+
app_id = db.Column(db.Integer, db.ForeignKey("installed_apps.id"), nullable=False, index=True)
|
|
220
|
+
key = db.Column(db.String(64), nullable=False)
|
|
221
|
+
value = db.Column(db.Text, nullable=True) # JSON string or plain text
|
|
222
|
+
value_type = db.Column(
|
|
223
|
+
db.String(16), default="json", nullable=False
|
|
224
|
+
) # json|string|boolean|integer|color
|
|
225
|
+
is_secret = db.Column(db.Boolean, default=False, nullable=False)
|
|
226
|
+
updated_at = db.Column(
|
|
227
|
+
db.DateTime,
|
|
228
|
+
default=lambda: datetime.now(timezone.utc),
|
|
229
|
+
onupdate=lambda: datetime.now(timezone.utc),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
__table_args__ = (db.UniqueConstraint("app_id", "key", name="uq_app_config_key"),)
|
|
233
|
+
|
|
234
|
+
def get_value(self):
|
|
235
|
+
"""Decode the stored value into a Python object based on value_type."""
|
|
236
|
+
import json
|
|
237
|
+
|
|
238
|
+
if self.value is None:
|
|
239
|
+
return None
|
|
240
|
+
if self.value_type == "json":
|
|
241
|
+
try:
|
|
242
|
+
return json.loads(self.value)
|
|
243
|
+
except Exception:
|
|
244
|
+
return self.value
|
|
245
|
+
if self.value_type == "boolean":
|
|
246
|
+
return self.value.lower() in ("1", "true", "yes", "on")
|
|
247
|
+
if self.value_type == "integer":
|
|
248
|
+
try:
|
|
249
|
+
return int(self.value)
|
|
250
|
+
except Exception:
|
|
251
|
+
return None
|
|
252
|
+
return self.value # string, color
|
|
253
|
+
|
|
254
|
+
def set_value(self, val):
|
|
255
|
+
"""Encode a Python value into the stored string based on value_type."""
|
|
256
|
+
import json
|
|
257
|
+
|
|
258
|
+
if val is None:
|
|
259
|
+
self.value = None
|
|
260
|
+
elif self.value_type == "json":
|
|
261
|
+
self.value = json.dumps(val) if not isinstance(val, str) else val
|
|
262
|
+
elif self.value_type == "boolean":
|
|
263
|
+
self.value = "1" if val else "0"
|
|
264
|
+
elif self.value_type == "integer":
|
|
265
|
+
self.value = str(int(val))
|
|
266
|
+
else:
|
|
267
|
+
self.value = str(val)
|
|
268
|
+
|
|
269
|
+
def to_dict(self):
|
|
270
|
+
return {
|
|
271
|
+
"key": self.key,
|
|
272
|
+
"value": self.get_value(),
|
|
273
|
+
"value_type": self.value_type,
|
|
274
|
+
"is_secret": self.is_secret,
|
|
275
|
+
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class AppAdminPanel(db.Model):
|
|
280
|
+
"""
|
|
281
|
+
Declared admin panel for an installed app, parsed from its manifest on
|
|
282
|
+
install/upgrade. The framework mounts these at `/admin/apps/<slug>/<panel>`
|
|
283
|
+
with admin auth enforced at mount time.
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
__tablename__ = "app_admin_panels"
|
|
287
|
+
|
|
288
|
+
id = db.Column(db.Integer, primary_key=True)
|
|
289
|
+
app_id = db.Column(db.Integer, db.ForeignKey("installed_apps.id"), nullable=False, index=True)
|
|
290
|
+
panel_id = db.Column(db.String(64), nullable=False) # e.g. 'flair-presets'
|
|
291
|
+
label = db.Column(db.String(100), nullable=False) # e.g. 'Flair presets'
|
|
292
|
+
icon = db.Column(db.String(50), nullable=True)
|
|
293
|
+
endpoint = db.Column(
|
|
294
|
+
db.String(255), nullable=True
|
|
295
|
+
) # 'blueprint_name:endpoint' or None for generated form
|
|
296
|
+
sort_order = db.Column(db.Integer, default=0, nullable=False)
|
|
297
|
+
|
|
298
|
+
__table_args__ = (db.UniqueConstraint("app_id", "panel_id", name="uq_app_panel"),)
|
|
299
|
+
|
|
300
|
+
def to_dict(self):
|
|
301
|
+
return {
|
|
302
|
+
"panel_id": self.panel_id,
|
|
303
|
+
"label": self.label,
|
|
304
|
+
"icon": self.icon,
|
|
305
|
+
"endpoint": self.endpoint,
|
|
306
|
+
"sort_order": self.sort_order,
|
|
307
|
+
}
|
appmanager/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file for PEP 561.
|
appmanager/sdk.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AppManager Developer SDK.
|
|
3
|
+
|
|
4
|
+
Re-exports the lightweight `appmanager_sdk` package components for sub-applications and extensions,
|
|
5
|
+
providing a single, unified developer API across the platform.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from appmanager_sdk import (
|
|
9
|
+
AdminSection,
|
|
10
|
+
AppManager,
|
|
11
|
+
AppManagerClient,
|
|
12
|
+
AppManifest,
|
|
13
|
+
ScheduledTask,
|
|
14
|
+
Setting,
|
|
15
|
+
client,
|
|
16
|
+
get_current_user,
|
|
17
|
+
require_auth,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AppManifest",
|
|
22
|
+
"Setting",
|
|
23
|
+
"AdminSection",
|
|
24
|
+
"ScheduledTask",
|
|
25
|
+
"AppManagerClient",
|
|
26
|
+
"AppManager",
|
|
27
|
+
"client",
|
|
28
|
+
"get_current_user",
|
|
29
|
+
"require_auth",
|
|
30
|
+
]
|
appmanager/security.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import stat
|
|
7
|
+
import time
|
|
8
|
+
import zipfile
|
|
9
|
+
from functools import wraps
|
|
10
|
+
from typing import Dict, Optional, Tuple
|
|
11
|
+
from urllib.parse import urljoin, urlparse
|
|
12
|
+
|
|
13
|
+
from flask import abort, current_app, g, jsonify, request
|
|
14
|
+
|
|
15
|
+
# Security Constants
|
|
16
|
+
MAX_ZIP_EXTRACT_SIZE = 50 * 1024 * 1024 # 50 MB
|
|
17
|
+
MAX_ZIP_FILE_COUNT = 1000
|
|
18
|
+
CSRF_TOKEN_MAX_AGE = 3600 * 4 # 4 hours
|
|
19
|
+
|
|
20
|
+
_rate_limit_records: Dict[str, list] = {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_security_secret() -> str:
|
|
24
|
+
"""
|
|
25
|
+
Retrieves the application secret key for HMAC operations.
|
|
26
|
+
"""
|
|
27
|
+
return current_app.config.get("SECRET_KEY", "default-appmanager-security-secret")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def generate_csrf_token() -> str:
|
|
31
|
+
"""
|
|
32
|
+
Generates a cryptographically signed HMAC CSRF token containing a timestamp.
|
|
33
|
+
"""
|
|
34
|
+
secret = get_security_secret()
|
|
35
|
+
timestamp = str(int(time.time()))
|
|
36
|
+
# Use user ID if authenticated, else remote IP
|
|
37
|
+
identity = str(getattr(g, "current_user_id", request.remote_addr or "anon"))
|
|
38
|
+
signature = hmac.new(
|
|
39
|
+
secret.encode("utf-8"), f"{identity}:{timestamp}".encode("utf-8"), hashlib.sha256
|
|
40
|
+
).hexdigest()
|
|
41
|
+
return f"{timestamp}:{signature}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def validate_csrf_token(token: Optional[str]) -> bool:
|
|
45
|
+
"""
|
|
46
|
+
Validates a submitted CSRF token.
|
|
47
|
+
"""
|
|
48
|
+
if not token or ":" not in token:
|
|
49
|
+
return False
|
|
50
|
+
try:
|
|
51
|
+
timestamp_str, signature = token.split(":", 1)
|
|
52
|
+
token_time = int(timestamp_str)
|
|
53
|
+
current_time = int(time.time())
|
|
54
|
+
|
|
55
|
+
# Check expiration
|
|
56
|
+
if current_time - token_time > CSRF_TOKEN_MAX_AGE or token_time > current_time + 60:
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
secret = get_security_secret()
|
|
60
|
+
identity = str(getattr(g, "current_user_id", request.remote_addr or "anon"))
|
|
61
|
+
expected_sig = hmac.new(
|
|
62
|
+
secret.encode("utf-8"), f"{identity}:{timestamp_str}".encode("utf-8"), hashlib.sha256
|
|
63
|
+
).hexdigest()
|
|
64
|
+
|
|
65
|
+
return hmac.compare_digest(signature, expected_sig)
|
|
66
|
+
except Exception:
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def csrf_protect_required(f):
|
|
71
|
+
"""
|
|
72
|
+
Decorator for route handlers requiring CSRF validation on non-safe HTTP methods.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
@wraps(f)
|
|
76
|
+
def decorated_function(*args, **kwargs):
|
|
77
|
+
if request.method in ("POST", "PUT", "DELETE", "PATCH"):
|
|
78
|
+
token = (
|
|
79
|
+
request.form.get("csrf_token")
|
|
80
|
+
or request.headers.get("X-CSRFToken")
|
|
81
|
+
or request.headers.get("X-CSRF-Token")
|
|
82
|
+
)
|
|
83
|
+
if not validate_csrf_token(token):
|
|
84
|
+
if request.is_json or request.path.startswith("/api/"):
|
|
85
|
+
return jsonify({"error": "CSRF validation failed", "success": False}), 400
|
|
86
|
+
abort(400, description="CSRF validation failed. Please refresh and try again.")
|
|
87
|
+
return f(*args, **kwargs)
|
|
88
|
+
|
|
89
|
+
return decorated_function
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def is_safe_redirect_url(target: Optional[str]) -> bool:
|
|
93
|
+
"""
|
|
94
|
+
Ensures that a redirect target URL is safe and relative to the current host (prevents Open Redirect).
|
|
95
|
+
"""
|
|
96
|
+
if not target:
|
|
97
|
+
return False
|
|
98
|
+
# Avoid javascript:, data:, or protocol-relative // URLs
|
|
99
|
+
if target.startswith(("javascript:", "data:", "vbscript:")) or target.startswith("//"):
|
|
100
|
+
return False
|
|
101
|
+
ref_url = urlparse(request.host_url)
|
|
102
|
+
test_url = urlparse(urljoin(request.host_url, target))
|
|
103
|
+
return test_url.scheme in ("http", "https") and ref_url.netloc == test_url.netloc
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def is_safe_repo_url(repo_url: str) -> bool:
|
|
107
|
+
"""
|
|
108
|
+
Validates that a git repository URL is safe and does not contain option injection flags.
|
|
109
|
+
"""
|
|
110
|
+
if not repo_url or repo_url.startswith("-"):
|
|
111
|
+
return False
|
|
112
|
+
pattern = r"^(https?|git|ssh)://[^\s]+$|^git@[^\s]+:[^\s]+$"
|
|
113
|
+
return bool(re.match(pattern, repo_url.strip()))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def validate_entrypoint_path(app_dir: str, entry_point: str) -> Tuple[bool, str]:
|
|
117
|
+
"""
|
|
118
|
+
Ensures an entrypoint string does not escape the app directory via directory traversal.
|
|
119
|
+
"""
|
|
120
|
+
if ":" in entry_point:
|
|
121
|
+
module_name = entry_point.split(":", 1)[0]
|
|
122
|
+
else:
|
|
123
|
+
module_name = entry_point
|
|
124
|
+
|
|
125
|
+
# Disallow path separators in module name
|
|
126
|
+
if "/" in module_name or "\\" in module_name or ".." in module_name:
|
|
127
|
+
return False, "Entrypoint module name cannot contain path separators or parent references."
|
|
128
|
+
|
|
129
|
+
module_file = os.path.join(app_dir, f"{module_name}.py")
|
|
130
|
+
abs_module = os.path.abspath(module_file)
|
|
131
|
+
abs_app_dir = os.path.abspath(app_dir)
|
|
132
|
+
|
|
133
|
+
if not abs_module.startswith(abs_app_dir):
|
|
134
|
+
return False, "Entrypoint file escapes application directory."
|
|
135
|
+
|
|
136
|
+
return True, abs_module
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def check_rate_limit(key: str, limit: int = 5, window_seconds: int = 60) -> bool:
|
|
140
|
+
"""
|
|
141
|
+
In-memory sliding window rate limiter. Returns True if allowed, False if limit exceeded.
|
|
142
|
+
"""
|
|
143
|
+
now = time.time()
|
|
144
|
+
records = _rate_limit_records.get(key, [])
|
|
145
|
+
# Filter out entries outside the window
|
|
146
|
+
records = [t for t in records if now - t < window_seconds]
|
|
147
|
+
if len(records) >= limit:
|
|
148
|
+
_rate_limit_records[key] = records
|
|
149
|
+
return False
|
|
150
|
+
records.append(now)
|
|
151
|
+
_rate_limit_records[key] = records
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def extract_zip_safely(zip_path: str, target_dir: str) -> None:
|
|
156
|
+
"""
|
|
157
|
+
Extracts a zip file safely, rejecting symlinks, decompression bombs, and directory traversal.
|
|
158
|
+
Handles cross-platform archives, Windows backslashes, and ignores macOS resource forks.
|
|
159
|
+
"""
|
|
160
|
+
total_size = 0
|
|
161
|
+
total_files = 0
|
|
162
|
+
abs_target_dir = os.path.abspath(target_dir)
|
|
163
|
+
real_target_dir = os.path.realpath(abs_target_dir)
|
|
164
|
+
|
|
165
|
+
os.makedirs(abs_target_dir, exist_ok=True)
|
|
166
|
+
|
|
167
|
+
with zipfile.ZipFile(zip_path, "r") as zip_ref:
|
|
168
|
+
for member in zip_ref.infolist():
|
|
169
|
+
total_files += 1
|
|
170
|
+
total_size += member.file_size
|
|
171
|
+
|
|
172
|
+
if total_files > MAX_ZIP_FILE_COUNT:
|
|
173
|
+
raise ValueError(
|
|
174
|
+
f"Security error: ZIP contains too many files (limit: {MAX_ZIP_FILE_COUNT})."
|
|
175
|
+
)
|
|
176
|
+
if total_size > MAX_ZIP_EXTRACT_SIZE:
|
|
177
|
+
raise ValueError(
|
|
178
|
+
f"Security error: ZIP uncompressed size exceeds {MAX_ZIP_EXTRACT_SIZE // (1024 * 1024)}MB."
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Skip OS junk like __MACOSX resource forks and .DS_Store
|
|
182
|
+
clean_name = member.filename.replace("\\", "/")
|
|
183
|
+
if (
|
|
184
|
+
clean_name.startswith("__MACOSX/")
|
|
185
|
+
or "/__MACOSX/" in clean_name
|
|
186
|
+
or os.path.basename(clean_name).startswith("._")
|
|
187
|
+
or clean_name.endswith(".DS_Store")
|
|
188
|
+
):
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
# Check for symlink attribute only on Unix-created zip entries (create_system == 3)
|
|
192
|
+
if getattr(member, "create_system", 0) == 3:
|
|
193
|
+
mode = (member.external_attr >> 16) & 0xFFFF
|
|
194
|
+
if mode != 0 and stat.S_ISLNK(mode):
|
|
195
|
+
raise ValueError(
|
|
196
|
+
f"Security error: ZIP entry '{member.filename}' is a forbidden symlink."
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Check for directory traversal attempts
|
|
200
|
+
clean_rel = os.path.normpath(clean_name).lstrip("/\\")
|
|
201
|
+
if (
|
|
202
|
+
clean_rel.startswith("..")
|
|
203
|
+
or clean_rel.startswith("/")
|
|
204
|
+
or clean_rel.startswith("\\")
|
|
205
|
+
):
|
|
206
|
+
raise ValueError(
|
|
207
|
+
f"Security error: ZIP entry '{member.filename}' attempts path traversal."
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
dest_path = os.path.abspath(os.path.join(abs_target_dir, clean_rel))
|
|
211
|
+
# Verify destination path stays inside target_dir
|
|
212
|
+
if not (
|
|
213
|
+
dest_path == abs_target_dir
|
|
214
|
+
or dest_path.startswith(abs_target_dir + os.sep)
|
|
215
|
+
or os.path.realpath(dest_path).startswith(real_target_dir + os.sep)
|
|
216
|
+
):
|
|
217
|
+
raise ValueError(
|
|
218
|
+
f"Security error: ZIP entry '{member.filename}' attempts path traversal."
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if member.is_dir() or clean_name.endswith("/"):
|
|
222
|
+
os.makedirs(dest_path, exist_ok=True)
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
|
226
|
+
with zip_ref.open(member) as src, open(dest_path, "wb") as dst:
|
|
227
|
+
shutil.copyfileobj(src, dst)
|