Working .env setup
This commit is contained in:
@@ -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"
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
venv
|
venv
|
||||||
__pycache__
|
__pycache__
|
||||||
|
.env
|
||||||
@@ -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
|
||||||
@@ -1,5 +1,18 @@
|
|||||||
import time
|
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:
|
class TimingMiddleware:
|
||||||
def __init__(self, get_response):
|
def __init__(self, get_response):
|
||||||
self.get_response = get_response
|
self.get_response = get_response
|
||||||
@@ -8,3 +21,115 @@ class TimingMiddleware:
|
|||||||
request._start_time = time.time()
|
request._start_time = time.time()
|
||||||
response = self.get_response(request)
|
response = self.get_response(request)
|
||||||
return response
|
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)
|
||||||
@@ -54,6 +54,7 @@ MIDDLEWARE = [
|
|||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
'common.middleware.TimingMiddleware',
|
'common.middleware.TimingMiddleware',
|
||||||
|
'common.middleware.GateMiddleware',
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = 'common.urls'
|
ROOT_URLCONF = 'common.urls'
|
||||||
@@ -61,7 +62,7 @@ ROOT_URLCONF = 'common.urls'
|
|||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
'DIRS': [],
|
'DIRS': [BASE_DIR / 'templates'],
|
||||||
'APP_DIRS': True,
|
'APP_DIRS': True,
|
||||||
'OPTIONS': {
|
'OPTIONS': {
|
||||||
'context_processors': [
|
'context_processors': [
|
||||||
@@ -130,7 +131,10 @@ SESSION_COOKIE_AGE = 86400
|
|||||||
|
|
||||||
# Production Server BS over here
|
# Production Server BS over here
|
||||||
CSRF_TRUSTED_ORIGINS = [
|
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')
|
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||||
USE_X_FORWARDED_HOST = True
|
USE_X_FORWARDED_HOST = True
|
||||||
@@ -147,7 +151,15 @@ STATIC_URL = '/static/'
|
|||||||
STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')]
|
STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')]
|
||||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
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 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
|
SESSION_EXPIRE_AT_BROWSER_CLOSE = False
|
||||||
+8
-14
@@ -1,22 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
URL configuration for common project.
|
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.contrib import admin
|
||||||
from django.urls import path
|
from django.urls import include, path
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path('', include('landing.urls')),
|
||||||
|
path('', include('profiles.urls')),
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Packs Site - Restricted{% endblock %}</title>
|
||||||
|
{% load static %}
|
||||||
|
<link rel="stylesheet" href="{% static 'css/style.css' %}">
|
||||||
|
<link rel="icon" type="image/png" href="{% static 'Logo.png' %}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="gate-main">
|
||||||
|
<div class="gate-card">
|
||||||
|
<img src="{% static 'Logo.png' %}" alt="Packs" height="64">
|
||||||
|
<h1>Restricted Access</h1>
|
||||||
|
<p>This site is private. Enter the master password to continue.</p>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<p class="gate-error">{{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'landing:gate' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="password" name="password" placeholder="Master password" autofocus required>
|
||||||
|
<button type="submit">Enter</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Home - Packs{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Welcome</h1>
|
||||||
|
<p>Pack library and download area will live here.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -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'),
|
||||||
|
]
|
||||||
@@ -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')
|
||||||
@@ -1,3 +1,15 @@
|
|||||||
from django.contrib import admin
|
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')
|
||||||
@@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,3 +1,36 @@
|
|||||||
|
from django.conf import settings
|
||||||
from django.db import models
|
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}...)'
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Login - Packs{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Login</h1>
|
||||||
|
<form method="post" action="{% url 'profiles:login' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.as_p }}
|
||||||
|
<button type="submit">Log in</button>
|
||||||
|
</form>
|
||||||
|
<p>No account? <a href="{% url 'profiles:register' %}">Register</a></p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Register - Packs{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Register</h1>
|
||||||
|
<form method="post" action="{% url 'profiles:register' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.as_p }}
|
||||||
|
<button type="submit">Register</button>
|
||||||
|
</form>
|
||||||
|
<p>Already have an account? <a href="{% url 'profiles:login' %}">Log in</a></p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Settings - Packs{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Settings</h1>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
<ul class="messages">
|
||||||
|
{% for message in messages %}
|
||||||
|
<li>{{ message }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2>API Tokens</h2>
|
||||||
|
<p>Tokens let non-browser clients (like a Fabric mod) access the API and download packs.</p>
|
||||||
|
|
||||||
|
<form method="post" action="{% url 'profiles:token_create' %}" class="token-create">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="text" name="label" placeholder="Token label (e.g. Fabric updater)" required>
|
||||||
|
<button type="submit">Create token</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if tokens %}
|
||||||
|
<table class="token-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Token</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Last used</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for token in tokens %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ token.label }}</td>
|
||||||
|
<td><code>{{ token.token }}</code></td>
|
||||||
|
<td>{{ token.created_at }}</td>
|
||||||
|
<td>{{ token.last_used|default:"never" }}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="{% url 'profiles:token_delete' token.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit">Revoke</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p>No tokens yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -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/<int:token_id>/delete/', views.token_delete, name='token_delete'),
|
||||||
|
]
|
||||||
@@ -1,3 +1,70 @@
|
|||||||
from django.shortcuts import render
|
import secrets
|
||||||
|
|
||||||
# Create your views here.
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth import authenticate, login, logout
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
|
||||||
|
from django.shortcuts import redirect, render
|
||||||
|
|
||||||
|
from .models import ApiToken, UserProfile
|
||||||
|
|
||||||
|
|
||||||
|
def register(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = UserCreationForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
user = form.save()
|
||||||
|
UserProfile.objects.get_or_create(user=user)
|
||||||
|
login(request, user)
|
||||||
|
messages.success(request, 'Registration successful.')
|
||||||
|
return redirect('home')
|
||||||
|
else:
|
||||||
|
form = UserCreationForm()
|
||||||
|
return render(request, 'profiles/register.html', {'form': form})
|
||||||
|
|
||||||
|
|
||||||
|
def user_login(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = AuthenticationForm(request, data=request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
user = form.get_user()
|
||||||
|
login(request, user)
|
||||||
|
return redirect('home')
|
||||||
|
else:
|
||||||
|
form = AuthenticationForm(request)
|
||||||
|
return render(request, 'profiles/login.html', {'form': form})
|
||||||
|
|
||||||
|
|
||||||
|
def user_logout(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
logout(request)
|
||||||
|
return redirect('home')
|
||||||
|
return redirect('home')
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def settings_page(request):
|
||||||
|
tokens = request.user.api_tokens.all()
|
||||||
|
context = {'tokens': tokens}
|
||||||
|
return render(request, 'profiles/settings.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def token_create(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
label = request.POST.get('label', '').strip() or 'API token'
|
||||||
|
token = secrets.token_urlsafe(48)
|
||||||
|
ApiToken.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
token=token,
|
||||||
|
key_prefix=token[:8],
|
||||||
|
label=label,
|
||||||
|
)
|
||||||
|
messages.success(request, 'Token created.')
|
||||||
|
return redirect('profiles:settings')
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def token_delete(request, token_id):
|
||||||
|
ApiToken.objects.filter(id=token_id, user=request.user).delete()
|
||||||
|
return redirect('profiles:settings')
|
||||||
@@ -3,27 +3,23 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}J621{% endblock %}</title>
|
<title>{% block title %}Packs Site{% endblock %}</title>
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
<link rel="stylesheet" href="{% static 'css/style.css' %}">
|
<link rel="stylesheet" href="{% static 'css/style.css' %}">
|
||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
<!-- Font Awesome 7 (free) -->
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
|
||||||
<link rel="icon" type="image/png" href="{% static 'logo.png' %}">
|
<link rel="icon" type="image/png" href="{% static 'Logo.png' %}">
|
||||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
|
||||||
<script src="{% static 'js/follow_common.js' %}"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-bar">
|
<div class="app-bar">
|
||||||
<div class="app-bar-title">
|
<div class="app-bar-title">
|
||||||
{% block app_bar_logo %}
|
{% block app_bar_logo %}
|
||||||
<img src="{% static 'logo.png' %}" alt="j621 Gallery" height="32">
|
<img src="{% static 'Logo.png' %}" alt="Packs" height="32">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
<nav class="app-bar-nav">
|
<nav class="app-bar-nav">
|
||||||
<!-- Desktop version (visible on wider screens) -->
|
|
||||||
<span class="version-badge desktop-version">
|
<span class="version-badge desktop-version">
|
||||||
<i class="fas fa-tag"></i> {{ APP_ENV }}
|
<i class="fas fa-tag"></i> {{ APP_ENV }}
|
||||||
<span class="separator">•</span>
|
<span class="separator">•</span>
|
||||||
@@ -34,13 +30,13 @@
|
|||||||
<i class="fas fa-clock"></i> {{ PAGE_GEN_TIME }}
|
<i class="fas fa-clock"></i> {{ PAGE_GEN_TIME }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Mobile version (visible on small screens) -->
|
|
||||||
<span class="version-badge mobile-version">
|
<span class="version-badge mobile-version">
|
||||||
<i class="fas fa-code-branch"></i> {{ APP_ENV|slice:":1"|upper }}
|
<i class="fas fa-code-branch"></i> {{ APP_ENV|slice:":1"|upper }}
|
||||||
<i class="{{ OS_ICON }}" style="margin-left: 4px;"></i>
|
<i class="{{ OS_ICON }}" style="margin-left: 4px;"></i>
|
||||||
</span>
|
</span>
|
||||||
<a href="{% url 'index' %}">
|
|
||||||
<i class="fas fa-home"></i><span class="nav-text"> Library</span>
|
<a href="{% url 'landing:home' %}">
|
||||||
|
<i class="fas fa-home"></i><span class="nav-text"> Home</span>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,44 +47,23 @@
|
|||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<div class="user-bar">
|
<div class="user-bar">
|
||||||
<div class="user-avatar" onclick="toggleUserMenu()">
|
<div class="user-avatar" onclick="toggleUserMenu()">
|
||||||
{% if user.userprofile.avatar_md5 %}
|
{% if user.userprofile.avatar %}
|
||||||
<img src="{% url 'serve_thumbnail' user.userprofile.avatar_md5 %}" alt="Avatar">
|
<img src="{{ user.userprofile.avatar.url }}" alt="Avatar">
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="default-avatar">{{ user.username|first|upper }}</div>
|
<div class="default-avatar">{{ user.username|first|upper }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="user-menu" id="user-menu">
|
<div class="user-menu" id="user-menu">
|
||||||
<a href="{% url 'edit_profile' %}" class="user-edit">
|
{# TODO: wire edit profile page when it exists #}
|
||||||
|
<a href="{% url 'profiles:settings' %}" class="user-edit">
|
||||||
<i class="fas fa-user-circle"></i> {{ user.username }}
|
<i class="fas fa-user-circle"></i> {{ user.username }}
|
||||||
{% if user.userprofile.e621_username %}
|
|
||||||
<span class="e621-user">({{ user.userprofile.e621_username }})</span>
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
</a>
|
||||||
<div class="user-info"></div>
|
<div class="user-info"></div>
|
||||||
<a href="{% url 'upload_page' %}">
|
{# TODO: wire upload / library pages when they exist #}
|
||||||
<i class="fas fa-upload"></i> Upload
|
<a href="{% url 'profiles:settings' %}">
|
||||||
|
<i class="fas fa-cog"></i> Settings
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'duplicates_page' %}">
|
<form method="post" action="{% url 'profiles:logout' %}" style="display: inline;">
|
||||||
<i class="fas fa-copy"></i> Duplicates
|
|
||||||
</a>
|
|
||||||
<a href="{% url 'delete_page' %}">
|
|
||||||
<i class="fas fa-trash-alt"></i> Delete
|
|
||||||
</a>
|
|
||||||
<a href="{% url 'follows:followed_tags' %}">
|
|
||||||
<i class="fa-solid fa-tags"></i> Followed Tags
|
|
||||||
</a>
|
|
||||||
<a href="{% url 'follows:followed_pools' %}">
|
|
||||||
<i class="fa-solid fa-boxes-stacked"></i> Followed Pools
|
|
||||||
</a>
|
|
||||||
{% if user.is_staff %}
|
|
||||||
<a href="{% url 'admin:index' %}">
|
|
||||||
<i class="fas fa-shield-alt"></i> Admin
|
|
||||||
</a>
|
|
||||||
<a href="{% url 'common:stats_dashboard' %}">
|
|
||||||
<i class="fa-solid fa-chart-bar"></i> Stats
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
<form method="post" action="{% url 'logout' %}" style="display: inline;">
|
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="logout-button">
|
<button type="submit" class="logout-button">
|
||||||
<i class="fas fa-sign-out-alt"></i> Logout
|
<i class="fas fa-sign-out-alt"></i> Logout
|
||||||
@@ -123,8 +98,8 @@
|
|||||||
</script>
|
</script>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="auth-bar">
|
<div class="auth-bar">
|
||||||
<a href="{% url 'login' %}"><i class="fas fa-sign-in-alt"></i> Login</a> |
|
<a href="{% url 'profiles:login' %}"><i class="fas fa-sign-in-alt"></i> Login</a> |
|
||||||
<a href="{% url 'register' %}"><i class="fas fa-user-plus"></i> Register</a>
|
<a href="{% url 'profiles:register' %}"><i class="fas fa-user-plus"></i> Register</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user