Upload 7 files
Browse files- pyrunner/__init__.py +0 -0
- pyrunner/asgi.py +16 -0
- pyrunner/middleware.py +24 -0
- pyrunner/settings.py +388 -0
- pyrunner/urls.py +47 -0
- pyrunner/version.py +11 -0
- pyrunner/wsgi.py +16 -0
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()
|