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
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)