Akoda35 commited on
Commit
12a8d6a
·
verified ·
1 Parent(s): a9d395b

Upload 7 files

Browse files
pyrunner/__init__.py ADDED
File without changes
pyrunner/asgi.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ASGI config for pyrunner project.
3
+
4
+ It exposes the ASGI callable as a module-level variable named ``application``.
5
+
6
+ For more information on this file, see
7
+ https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
8
+ """
9
+
10
+ import os
11
+
12
+ from django.core.asgi import get_asgi_application
13
+
14
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pyrunner.settings")
15
+
16
+ application = get_asgi_application()
pyrunner/middleware.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Custom middleware for PyRunner."""
2
+
3
+
4
+ class NoCacheMiddleware:
5
+ """Prevent browser caching of HTML responses.
6
+
7
+ This ensures users always see fresh data when navigating the dashboard,
8
+ without needing to hard-refresh the browser.
9
+ """
10
+
11
+ def __init__(self, get_response):
12
+ self.get_response = get_response
13
+
14
+ def __call__(self, request):
15
+ response = self.get_response(request)
16
+
17
+ # Only add no-cache headers to HTML responses
18
+ content_type = response.get("Content-Type", "")
19
+ if "text/html" in content_type:
20
+ response["Cache-Control"] = "no-cache, no-store, must-revalidate"
21
+ response["Pragma"] = "no-cache"
22
+ response["Expires"] = "0"
23
+
24
+ return response
pyrunner/settings.py ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Django settings for pyrunner project.
3
+
4
+ Generated by 'django-admin startproject' using Django 6.0.1.
5
+
6
+ For more information on this file, see
7
+ https://docs.djangoproject.com/en/6.0/topics/settings/
8
+
9
+ For the full list of settings and their values, see
10
+ https://docs.djangoproject.com/en/6.0/ref/settings/
11
+ """
12
+
13
+ import os
14
+ import shutil
15
+ from pathlib import Path
16
+
17
+ from django.core.exceptions import ImproperlyConfigured
18
+ from dotenv import load_dotenv
19
+
20
+ # Build paths inside the project like this: BASE_DIR / 'subdir'.
21
+ BASE_DIR = Path(__file__).resolve().parent.parent
22
+
23
+ # Load environment variables from .env file
24
+ load_dotenv(BASE_DIR / ".env")
25
+
26
+
27
+ # Quick-start development settings - unsuitable for production
28
+ # See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
29
+
30
+ # SECURITY WARNING: keep the secret key used in production secret!
31
+ # SECRET_KEY is required - generate one with: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
32
+ SECRET_KEY = os.environ["SECRET_KEY"]
33
+
34
+ # SECURITY WARNING: don't run with debug turned on in production!
35
+ # Defaults to False for security - set DEBUG=True explicitly for development
36
+ DEBUG = os.environ.get("DEBUG", "False").lower() == "true"
37
+
38
+ ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
39
+
40
+
41
+ # Application definition
42
+
43
+ INSTALLED_APPS = [
44
+ "django.contrib.admin",
45
+ "django.contrib.auth",
46
+ "django.contrib.contenttypes",
47
+ "django.contrib.sessions",
48
+ "django.contrib.messages",
49
+ "django.contrib.staticfiles",
50
+ # Third party
51
+ "django_q",
52
+ "tailwind",
53
+ "theme",
54
+ "axes",
55
+ # Local apps
56
+ "core",
57
+ ]
58
+
59
+ # Tailwind CSS
60
+ TAILWIND_APP_NAME = "theme"
61
+ INTERNAL_IPS = ["127.0.0.1"]
62
+ NPM_BIN_PATH = os.environ.get("NPM_BIN_PATH", shutil.which("npm") or "npm")
63
+
64
+ MIDDLEWARE = [
65
+ "django.middleware.security.SecurityMiddleware",
66
+ "whitenoise.middleware.WhiteNoiseMiddleware",
67
+ "pyrunner.middleware.NoCacheMiddleware",
68
+ "django.contrib.sessions.middleware.SessionMiddleware",
69
+ "django.middleware.common.CommonMiddleware",
70
+ "django.middleware.csrf.CsrfViewMiddleware",
71
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
72
+ "axes.middleware.AxesMiddleware",
73
+ "django.contrib.messages.middleware.MessageMiddleware",
74
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
75
+ "core.middleware.SetupWizardMiddleware",
76
+ ]
77
+
78
+ ROOT_URLCONF = "pyrunner.urls"
79
+
80
+ TEMPLATES = [
81
+ {
82
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
83
+ "DIRS": [BASE_DIR / "templates"],
84
+ "APP_DIRS": True,
85
+ "OPTIONS": {
86
+ "context_processors": [
87
+ "django.template.context_processors.request",
88
+ "django.contrib.auth.context_processors.auth",
89
+ "django.contrib.messages.context_processors.messages",
90
+ "core.context_processors.pyrunner_version",
91
+ ],
92
+ },
93
+ },
94
+ ]
95
+
96
+ WSGI_APPLICATION = "pyrunner.wsgi.application"
97
+
98
+
99
+ # Database
100
+ # https://docs.djangoproject.com/en/6.0/ref/settings/#databases
101
+
102
+ DATABASES = {
103
+ "default": {
104
+ "ENGINE": "django.db.backends.sqlite3",
105
+ "NAME": BASE_DIR / "data" / "db.sqlite3",
106
+ "OPTIONS": {
107
+ "timeout": 30, # Wait up to 30 seconds for database lock
108
+ },
109
+ }
110
+ }
111
+
112
+
113
+ # Enable SQLite WAL mode for better concurrency in multi-process environments
114
+ def _enable_sqlite_wal(sender, connection, **kwargs):
115
+ if connection.vendor == "sqlite":
116
+ cursor = connection.cursor()
117
+ cursor.execute("PRAGMA journal_mode=WAL;")
118
+ cursor.execute("PRAGMA busy_timeout=30000;")
119
+
120
+
121
+ from django.db.backends.signals import connection_created
122
+
123
+ connection_created.connect(_enable_sqlite_wal)
124
+
125
+
126
+ # Custom User Model
127
+ AUTH_USER_MODEL = "core.User"
128
+
129
+ # Auth URLs
130
+ LOGIN_URL = "auth:login"
131
+ LOGIN_REDIRECT_URL = "cpanel:dashboard"
132
+
133
+
134
+ # Password validation
135
+ # https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
136
+
137
+ AUTH_PASSWORD_VALIDATORS = [
138
+ {
139
+ "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
140
+ },
141
+ {
142
+ "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
143
+ },
144
+ {
145
+ "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
146
+ },
147
+ {
148
+ "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
149
+ },
150
+ ]
151
+
152
+ # Authentication backends (django-axes for brute force protection)
153
+ AUTHENTICATION_BACKENDS = [
154
+ "axes.backends.AxesStandaloneBackend",
155
+ "django.contrib.auth.backends.ModelBackend",
156
+ ]
157
+
158
+ # Django Axes Configuration (login rate limiting)
159
+ AXES_FAILURE_LIMIT = int(os.environ.get("AXES_FAILURE_LIMIT", "5")) # Lock after 5 failed attempts
160
+ AXES_COOLOFF_TIME = 0.25 # 15 minutes lockout (in hours)
161
+ AXES_RESET_ON_SUCCESS = True # Reset failed attempts after successful login
162
+ AXES_LOCKOUT_PARAMETERS = [["username", "ip_address"]] # Combined lockout by username AND IP
163
+ AXES_ENABLE_ACCESS_FAILURE_LOG = True # Log failed attempts
164
+ AXES_VERBOSE = False # Don't log every access attempt
165
+
166
+
167
+ # Internationalization
168
+ # https://docs.djangoproject.com/en/6.0/topics/i18n/
169
+
170
+ LANGUAGE_CODE = "en-us"
171
+
172
+ TIME_ZONE = "UTC"
173
+
174
+ USE_I18N = True
175
+
176
+ USE_TZ = True
177
+
178
+
179
+ # Static files (CSS, JavaScript, Images)
180
+ # https://docs.djangoproject.com/en/6.0/howto/static-files/
181
+
182
+ STATIC_URL = "static/"
183
+ STATICFILES_DIRS = [BASE_DIR / "static"]
184
+ STATIC_ROOT = BASE_DIR / "staticfiles"
185
+
186
+ # WhiteNoise for serving static files in production
187
+ STORAGES = {
188
+ "default": {
189
+ "BACKEND": "django.core.files.storage.FileSystemStorage",
190
+ },
191
+ "staticfiles": {
192
+ "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
193
+ },
194
+ }
195
+
196
+
197
+ # Default primary key field type
198
+ # https://docs.djangoproject.com/en/6.0/ref/settings/#default-auto-field
199
+
200
+ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
201
+
202
+
203
+ # Email Configuration
204
+ # Console backend for development, configure SMTP in production
205
+ EMAIL_BACKEND = os.environ.get(
206
+ "EMAIL_BACKEND",
207
+ "django.core.mail.backends.console.EmailBackend",
208
+ )
209
+ DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "noreply@pyrunner.local")
210
+
211
+ # Resend API Configuration (Production)
212
+ RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "")
213
+ USE_RESEND = os.environ.get("USE_RESEND", "False").lower() == "true"
214
+
215
+
216
+ # django-q2 Configuration
217
+ def _get_q_cluster_config():
218
+ """
219
+ Get Q_CLUSTER configuration from database with environment fallbacks.
220
+
221
+ This is called at module load time. Database values take precedence
222
+ over environment variables when available.
223
+ """
224
+ # Default values (from env vars or hardcoded defaults)
225
+ config = {
226
+ "name": "PyRunner",
227
+ "workers": int(os.environ.get("Q_WORKERS", 2)),
228
+ "timeout": 600 if os.name != "nt" else 0,
229
+ "retry": 660,
230
+ "queue_limit": 20,
231
+ "bulk": 5,
232
+ "orm": "default",
233
+ "catch_up": False,
234
+ }
235
+
236
+ # Try to load from database (only if apps are ready to avoid initialization warnings)
237
+ try:
238
+ from django.apps import apps
239
+
240
+ if not apps.ready:
241
+ return config # Return defaults during app initialization
242
+
243
+ from django.db import connection
244
+ from django.db.utils import OperationalError, ProgrammingError
245
+
246
+ with connection.cursor() as cursor:
247
+ cursor.execute(
248
+ "SELECT q_workers, q_timeout, q_retry, q_queue_limit "
249
+ "FROM global_settings WHERE id = 1"
250
+ )
251
+ row = cursor.fetchone()
252
+ if row:
253
+ config["workers"] = row[0] or config["workers"]
254
+ # On Windows, force timeout to 0 regardless of DB value
255
+ if os.name == "nt":
256
+ config["timeout"] = 0
257
+ else:
258
+ config["timeout"] = row[1] if row[1] is not None else config["timeout"]
259
+ config["retry"] = row[2] or config["retry"]
260
+ config["queue_limit"] = row[3] or config["queue_limit"]
261
+ except (OperationalError, ProgrammingError, Exception):
262
+ # Database not ready yet (migrations not run), use defaults
263
+ pass
264
+
265
+ return config
266
+
267
+
268
+ Q_CLUSTER = _get_q_cluster_config()
269
+
270
+
271
+ # PyRunner Configuration
272
+ DATA_DIR = BASE_DIR / "data"
273
+ ENVIRONMENTS_ROOT = DATA_DIR / "environments"
274
+ SCRIPTS_WORKDIR = DATA_DIR / "workdir"
275
+
276
+ # Ensure directories exist
277
+ DATA_DIR.mkdir(exist_ok=True)
278
+ ENVIRONMENTS_ROOT.mkdir(exist_ok=True)
279
+ SCRIPTS_WORKDIR.mkdir(exist_ok=True)
280
+
281
+
282
+ # Secrets Encryption
283
+ # Generate a key with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
284
+ ENCRYPTION_KEY = os.environ.get("ENCRYPTION_KEY", "")
285
+
286
+ # Validate ENCRYPTION_KEY
287
+ if not ENCRYPTION_KEY:
288
+ if not DEBUG:
289
+ raise ImproperlyConfigured(
290
+ "ENCRYPTION_KEY is required in production. "
291
+ "Generate one with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\""
292
+ )
293
+ else:
294
+ # Validate key format
295
+ try:
296
+ from cryptography.fernet import Fernet
297
+ Fernet(ENCRYPTION_KEY.encode() if isinstance(ENCRYPTION_KEY, str) else ENCRYPTION_KEY)
298
+ except Exception:
299
+ raise ImproperlyConfigured("ENCRYPTION_KEY is invalid. Must be a valid Fernet key.")
300
+
301
+
302
+ # Logging Configuration
303
+ LOGS_DIR = DATA_DIR / "logs"
304
+ LOGS_DIR.mkdir(exist_ok=True)
305
+
306
+ LOGGING = {
307
+ "version": 1,
308
+ "disable_existing_loggers": False,
309
+ "formatters": {
310
+ "verbose": {
311
+ "format": "[{asctime}] {levelname} {name} {message}",
312
+ "style": "{",
313
+ "datefmt": "%Y-%m-%d %H:%M:%S",
314
+ },
315
+ "json": {
316
+ "()": "core.logging.JsonFormatter",
317
+ },
318
+ },
319
+ "handlers": {
320
+ "console": {
321
+ "class": "logging.StreamHandler",
322
+ "formatter": "verbose",
323
+ },
324
+ "file": {
325
+ "class": "logging.handlers.RotatingFileHandler",
326
+ "filename": LOGS_DIR / "pyrunner.log",
327
+ "maxBytes": 10 * 1024 * 1024, # 10 MB
328
+ "backupCount": 5,
329
+ "formatter": "json",
330
+ "encoding": "utf-8",
331
+ },
332
+ },
333
+ "root": {
334
+ "handlers": ["console", "file"],
335
+ "level": "INFO",
336
+ },
337
+ "loggers": {
338
+ "django": {
339
+ "handlers": ["console", "file"],
340
+ "level": "WARNING",
341
+ "propagate": False,
342
+ },
343
+ "core": {
344
+ "handlers": ["console", "file"],
345
+ "level": "INFO",
346
+ "propagate": False,
347
+ },
348
+ },
349
+ }
350
+
351
+
352
+ # Security Settings
353
+ # These settings are applied when DEBUG=False (production)
354
+ if not DEBUG:
355
+ # HTTPS/SSL Settings
356
+ SECURE_SSL_REDIRECT = os.environ.get("SECURE_SSL_REDIRECT", "True").lower() == "true"
357
+ SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
358
+
359
+ # HSTS - HTTP Strict Transport Security
360
+ # Start with a shorter duration, increase to 31536000 (1 year) once confirmed working
361
+ SECURE_HSTS_SECONDS = int(os.environ.get("SECURE_HSTS_SECONDS", "2592000")) # 30 days default
362
+ SECURE_HSTS_INCLUDE_SUBDOMAINS = os.environ.get("SECURE_HSTS_INCLUDE_SUBDOMAINS", "True").lower() == "true"
363
+ SECURE_HSTS_PRELOAD = os.environ.get("SECURE_HSTS_PRELOAD", "False").lower() == "true"
364
+
365
+ # Cookie Security
366
+ SESSION_COOKIE_SECURE = True
367
+ CSRF_COOKIE_SECURE = True
368
+
369
+ # Always-on security settings (regardless of DEBUG)
370
+ SECURE_CONTENT_TYPE_NOSNIFF = True
371
+ X_FRAME_OPTIONS = "DENY"
372
+
373
+ # Note: For Content Security Policy (CSP), install django-csp and add to middleware:
374
+ # pip install django-csp
375
+ # Add 'csp.middleware.CSPMiddleware' to MIDDLEWARE
376
+ # Then configure CSP_* settings as needed
377
+
378
+
379
+ # =============================================================================
380
+ # API Settings
381
+ # =============================================================================
382
+
383
+ # Rate limiting for API requests (requests per minute per token)
384
+ API_RATE_LIMIT = int(os.environ.get("API_RATE_LIMIT", "60"))
385
+
386
+ # CORS settings for API endpoints
387
+ # Set to "*" to allow all origins (suitable for self-hosted), or comma-separated origins
388
+ API_CORS_ORIGINS = os.environ.get("API_CORS_ORIGINS", "*")
pyrunner/urls.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ URL configuration for pyrunner project.
3
+
4
+ The `urlpatterns` list routes URLs to views. For more information please see:
5
+ https://docs.djangoproject.com/en/6.0/topics/http/urls/
6
+ Examples:
7
+ Function views
8
+ 1. Add an import: from my_app import views
9
+ 2. Add a URL to urlpatterns: path('', views.home, name='home')
10
+ Class-based views
11
+ 1. Add an import: from other_app.views import Home
12
+ 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
13
+ Including another URLconf
14
+ 1. Import the include() function: from django.urls import include, path
15
+ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
16
+ """
17
+
18
+ from django.contrib import admin
19
+ from django.urls import path, include
20
+ from django.shortcuts import redirect
21
+
22
+ from core.views.webhooks import webhook_trigger_view
23
+
24
+
25
+ def get_admin_url_slug():
26
+ """
27
+ Get admin URL slug from settings.
28
+ Called at startup - changes require app restart.
29
+ """
30
+ try:
31
+ from core.models import GlobalSettings
32
+ return GlobalSettings.get_settings().admin_url_slug or "django-admin"
33
+ except Exception:
34
+ return "django-admin"
35
+
36
+
37
+ urlpatterns = [
38
+ path(f"{get_admin_url_slug()}/", admin.site.urls),
39
+ path("setup/", include("core.urls.setup")),
40
+ path("auth/", include("core.urls.auth")),
41
+ path("cpanel/", include("core.urls.cpanel")),
42
+ # REST API endpoints (token auth required)
43
+ path("api/v1/", include("core.urls.api")),
44
+ # Public webhook endpoint (no auth required)
45
+ path("webhook/<str:token>/", webhook_trigger_view, name="webhook_trigger"),
46
+ path("", lambda request: redirect("auth:login")),
47
+ ]
pyrunner/version.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PyRunner version information.
3
+ """
4
+
5
+ __version__ = "1.8.2"
6
+ VERSION = __version__
7
+
8
+
9
+ def get_version():
10
+ """Return the current PyRunner version."""
11
+ return __version__
pyrunner/wsgi.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ WSGI config for pyrunner project.
3
+
4
+ It exposes the WSGI callable as a module-level variable named ``application``.
5
+
6
+ For more information on this file, see
7
+ https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
8
+ """
9
+
10
+ import os
11
+
12
+ from django.core.wsgi import get_wsgi_application
13
+
14
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pyrunner.settings")
15
+
16
+ application = get_wsgi_application()