+ Restricted Access
+This site is private. Enter the master password to continue.
+ + {% if error %} +{{ error }}
+ {% endif %} + + +diff --git a/.env.txt b/.env.txt new file mode 100644 index 0000000..2d09ae5 --- /dev/null +++ b/.env.txt @@ -0,0 +1,11 @@ +CONTAINER_NAME="packs_db" +DB_HOST=127.0.0.1 +DB_PORT=3406 +DB_NAME=default +DB_USER=default_user +DB_PASSWORD= +DB_ROOT_PASSWORD= +DB_PATH="./Packs_DB" +SECRET_KEY= +PASSWORD= +DEBUG="False" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7f93ebf..771ba65 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ venv __pycache__ +.env \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..602ee4f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + mariadb: + image: mariadb:10.11 + container_name: ${CONTAINER_NAME} + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + MYSQL_DATABASE: ${DB_NAME} + MYSQL_USER: ${DB_USER} + MYSQL_PASSWORD: ${DB_PASSWORD} + volumes: + - "${DB_PATH:-./mariadb-data}:/var/lib/mysql" + ports: + - "${DB_HOST}:${DB_PORT}:3306" + networks: + - internal + +networks: + internal: + driver: bridge \ No newline at end of file diff --git a/nonpacks/common/middleware.py b/nonpacks/common/middleware.py index 41f1358..83a6dca 100644 --- a/nonpacks/common/middleware.py +++ b/nonpacks/common/middleware.py @@ -1,5 +1,18 @@ import time +from django.conf import settings +from django.http import HttpResponseRedirect, JsonResponse +from django.urls import Resolver404, resolve + +try: + from django.utils.timezone import now +except Exception: # pragma: no cover + from datetime import datetime + + def now(): + return datetime.now() + + class TimingMiddleware: def __init__(self, get_response): self.get_response = get_response @@ -7,4 +20,116 @@ class TimingMiddleware: def __call__(self, request): request._start_time = time.time() response = self.get_response(request) - return response \ No newline at end of file + return response + + +def _is_whitelisted(request): + """Check whether the resolved view is marked with @gate_whitelist.""" + try: + resolver_match = resolve(request.path_info) + except Resolver404: + return False + func = resolver_match.func + if getattr(func, '__gate_whitelist__', False): + return True + view_class = getattr(func, 'view_class', None) + if view_class and getattr(view_class, '__gate_whitelist__', False): + return True + return False + + +def _is_token_request(request): + """Token is honoured only for non-browser download/API requests.""" + path = request.path_info + if path.startswith('/api/') or path.startswith('/media/'): + return True + accept = request.META.get('HTTP_ACCEPT', '') + if 'application/json' in accept: + return True + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return True + return False + + +def _is_api_request(request): + """Requests that must 401 (never redirect to an HTML page).""" + path = request.path_info + if path.startswith('/api/') or path.startswith('/media/'): + return True + accept = request.META.get('HTTP_ACCEPT', '') + if 'application/json' in accept: + return True + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + return True + return False + + +def _get_bearer_token(request): + auth = request.headers.get('Authorization', '') + if auth.lower().startswith('bearer '): + return auth.split(' ', 1)[1].strip() + explicit = request.headers.get('X-Auth-Token') + return explicit or None + + +def _authenticate_token(request, token): + import hmac + + from django.utils import timezone + + from profiles.models import ApiToken + + if not token: + return False + prefix = token[:8] + # Matches on the prefix keyed index, then constant-time compares. + for candidate in ApiToken.objects.filter(key_prefix=prefix): + if hmac.compare_digest(candidate.token or '', token): + candidate.last_used = timezone.now() + candidate.save(update_fields=['last_used']) + setattr(request, 'user', candidate.user) + setattr(request, '_cached_user', candidate.user) + return True + return False + + +def gate_whitelist(view_func): + """Mark a view as public (bypasses the gate).""" + view_func.__gate_whitelist__ = True + return view_func + + +class GateMiddleware: + """Gate every request behind the master-password session or an API token. + + Public views must be decorated with @gate_whitelist. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + # Public static assets must load on the gate page itself. + if request.path_info.startswith(settings.STATIC_URL): + return self.get_response(request) + + # @gate_whitelist views (gate page + its POST) pass through. + if _is_whitelisted(request): + return self.get_response(request) + + # API/media requests are allowed a bearer token to bypass the gate + # (non-browser downloaders such as the Fabric mod). + if _is_token_request(request): + token = _get_bearer_token(request) + if _authenticate_token(request, token): + return self.get_response(request) + # fall through: reject with 401 for API/media. + + # Browser session authorized by the master password. + if request.session.get('authorized'): + return self.get_response(request) + + # Unauthorized: reject. + if _is_api_request(request): + return JsonResponse({'detail': 'Unauthorized'}, status=401) + return HttpResponseRedirect(settings.GATE_URL) \ No newline at end of file diff --git a/nonpacks/common/settings.py b/nonpacks/common/settings.py index c842aea..5117420 100644 --- a/nonpacks/common/settings.py +++ b/nonpacks/common/settings.py @@ -54,6 +54,7 @@ MIDDLEWARE = [ 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'common.middleware.TimingMiddleware', + 'common.middleware.GateMiddleware', ] ROOT_URLCONF = 'common.urls' @@ -61,7 +62,7 @@ ROOT_URLCONF = 'common.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], + 'DIRS': [BASE_DIR / 'templates'], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -130,7 +131,10 @@ SESSION_COOKIE_AGE = 86400 # Production Server BS over here CSRF_TRUSTED_ORIGINS = [ - 'https://jakerasp.rainbow-herring.ts.net' + 'https://jakerasp.rainbow-herring.ts.net', + 'http://127.0.0.1' + 'http://jakerasp', + 'http://localhost' ] SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') USE_X_FORWARDED_HOST = True @@ -147,7 +151,15 @@ STATIC_URL = '/static/' STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')] STATIC_ROOT = BASE_DIR / 'staticfiles' +# Media (pack downloads, user assets) — served through Django behind the gate +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' + # Login settings -LOGIN_URL = 'login' +LOGIN_URL = 'profiles:login' +LOGIN_REDIRECT_URL = '/' + +# Gate settings — URL the GateMiddleware redirects HTML pages to +GATE_URL = '/' SESSION_EXPIRE_AT_BROWSER_CLOSE = False \ No newline at end of file diff --git a/nonpacks/common/urls.py b/nonpacks/common/urls.py index 85e125d..c8a68ce 100644 --- a/nonpacks/common/urls.py +++ b/nonpacks/common/urls.py @@ -1,22 +1,16 @@ """ URL configuration for common project. - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/6.0/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ +from django.conf import settings +from django.conf.urls.static import static from django.contrib import admin -from django.urls import path +from django.urls import include, path urlpatterns = [ + path('', include('landing.urls')), + path('', include('profiles.urls')), path('admin/', admin.site.urls), ] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \ No newline at end of file diff --git a/nonpacks/landing/templates/landing/gate.html b/nonpacks/landing/templates/landing/gate.html new file mode 100644 index 0000000..3358c0d --- /dev/null +++ b/nonpacks/landing/templates/landing/gate.html @@ -0,0 +1,30 @@ + + +
+ + +
+ This site is private. Enter the master password to continue.
+ + {% if error %} +{{ error }}
+ {% endif %} + + +Pack library and download area will live here.
+{% endblock %} \ No newline at end of file diff --git a/nonpacks/landing/urls.py b/nonpacks/landing/urls.py new file mode 100644 index 0000000..43e6a8a --- /dev/null +++ b/nonpacks/landing/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from . import views + +app_name = 'landing' + +urlpatterns = [ + path('', views.gate, name='gate'), + path('home/', views.home, name='home'), +] \ No newline at end of file diff --git a/nonpacks/landing/views.py b/nonpacks/landing/views.py index 91ea44a..c2c0b5c 100644 --- a/nonpacks/landing/views.py +++ b/nonpacks/landing/views.py @@ -1,3 +1,50 @@ -from django.shortcuts import render +import hmac +import random +import time -# Create your views here. +from django.conf import settings +from django.shortcuts import redirect, render + +from common.middleware import gate_whitelist + + +def _under_lockout(request): + lock_until = request.session.get('lock_until') + if lock_until and time.time() < lock_until: + return True + return False + + +def _start_lockout(request): + duration = random.randint(30, 300) + request.session['lock_until'] = time.time() + duration + + +@gate_whitelist +def gate(request): + if request.method == 'POST': + if _under_lockout(request): + return render(request, 'landing/gate.html', { + 'error': 'Incorrect password.', + }) + + submitted = request.POST.get('password', '') + master = settings.PASSWORD or '' + if hmac.compare_digest(submitted, master): + request.session['authorized'] = True + request.session.pop('lock_until', None) + return redirect('home') + else: + _start_lockout(request) + return render(request, 'landing/gate.html', { + 'error': 'Incorrect password.', + }) + + if request.session.get('authorized'): + return redirect('home') + + return render(request, 'landing/gate.html') + + +def home(request): + return render(request, 'landing/home.html') \ No newline at end of file diff --git a/nonpacks/profiles/admin.py b/nonpacks/profiles/admin.py index 8c38f3f..106ecf8 100644 --- a/nonpacks/profiles/admin.py +++ b/nonpacks/profiles/admin.py @@ -1,3 +1,15 @@ from django.contrib import admin -# Register your models here. +from .models import ApiToken, UserProfile + + +@admin.register(UserProfile) +class UserProfileAdmin(admin.ModelAdmin): + list_display = ('user', 'joined') + search_fields = ('user__username',) + + +@admin.register(ApiToken) +class ApiTokenAdmin(admin.ModelAdmin): + list_display = ('user', 'label', 'key_prefix', 'created_at', 'last_used') + search_fields = ('user__username', 'label') \ No newline at end of file diff --git a/nonpacks/profiles/migrations/0001_initial.py b/nonpacks/profiles/migrations/0001_initial.py new file mode 100644 index 0000000..e79d85d --- /dev/null +++ b/nonpacks/profiles/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# Generated by Django 6.0.3 on 2026-08-03 14:54 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ApiToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token', models.TextField()), + ('key_prefix', models.CharField(db_index=True, max_length=8)), + ('label', models.CharField(default='API token', max_length=64)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('last_used', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='UserProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('bio', models.TextField(blank=True, default='')), + ('avatar', models.ImageField(blank=True, null=True, upload_to='profiles/%d/')), + ('joined', models.DateTimeField(auto_now_add=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='userprofile', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/nonpacks/profiles/models.py b/nonpacks/profiles/models.py index 71a8362..1373f9d 100644 --- a/nonpacks/profiles/models.py +++ b/nonpacks/profiles/models.py @@ -1,3 +1,36 @@ +from django.conf import settings from django.db import models -# Create your models here. + +class UserProfile(models.Model): + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='userprofile', + ) + bio = models.TextField(blank=True, default='') + avatar = models.ImageField( + upload_to='profiles/%d/', + blank=True, + null=True, + ) + joined = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f'{self.user.username} profile' + + +class ApiToken(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='api_tokens', + ) + token = models.TextField() + key_prefix = models.CharField(max_length=8, db_index=True) + label = models.CharField(max_length=64, default='API token') + created_at = models.DateTimeField(auto_now_add=True) + last_used = models.DateTimeField(null=True, blank=True) + + def __str__(self): + return f'{self.user.username} - {self.label} ({self.key_prefix}...)' \ No newline at end of file diff --git a/nonpacks/profiles/templates/profiles/login.html b/nonpacks/profiles/templates/profiles/login.html new file mode 100644 index 0000000..b643a32 --- /dev/null +++ b/nonpacks/profiles/templates/profiles/login.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} + +{% block title %}Login - Packs{% endblock %} + +{% block content %} +No account? Register
+{% endblock %} \ No newline at end of file diff --git a/nonpacks/profiles/templates/profiles/register.html b/nonpacks/profiles/templates/profiles/register.html new file mode 100644 index 0000000..014eed6 --- /dev/null +++ b/nonpacks/profiles/templates/profiles/register.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} + +{% block title %}Register - Packs{% endblock %} + +{% block content %} +Already have an account? Log in
+{% endblock %} \ No newline at end of file diff --git a/nonpacks/profiles/templates/profiles/settings.html b/nonpacks/profiles/templates/profiles/settings.html new file mode 100644 index 0000000..8b904e7 --- /dev/null +++ b/nonpacks/profiles/templates/profiles/settings.html @@ -0,0 +1,56 @@ +{% extends 'base.html' %} + +{% block title %}Settings - Packs{% endblock %} + +{% block content %} +Tokens let non-browser clients (like a Fabric mod) access the API and download packs.
+ + + + {% if tokens %} +| Label | +Token | +Created | +Last used | ++ |
|---|---|---|---|---|
| {{ token.label }} | +{{ token.token }} |
+ {{ token.created_at }} | +{{ token.last_used|default:"never" }} | ++ + | +
No tokens yet.
+ {% endif %} +{% endblock %} \ No newline at end of file diff --git a/nonpacks/profiles/urls.py b/nonpacks/profiles/urls.py new file mode 100644 index 0000000..7c47dee --- /dev/null +++ b/nonpacks/profiles/urls.py @@ -0,0 +1,14 @@ +from django.urls import path + +from . import views + +app_name = 'profiles' + +urlpatterns = [ + path('register/', views.register, name='register'), + path('login/', views.user_login, name='login'), + path('logout/', views.user_logout, name='logout'), + path('settings/', views.settings_page, name='settings'), + path('settings/tokens/create/', views.token_create, name='token_create'), + path('settings/tokens/