Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
5f5830a
Refactor AdventureLog Bot workflow to improve issue validation handli…
seanmorley15 Mar 16, 2026
1dcf99b
feat: add API key management to settings page
seanmorley15 Mar 16, 2026
cd85a73
feat: add API Keys documentation and update contributing guidelines
seanmorley15 Mar 16, 2026
8c03126
fix: update appVersion to reflect the latest build
seanmorley15 Mar 17, 2026
fa3a5e0
fix: update @tailwindcss/typography to version 0.5.19
seanmorley15 Mar 17, 2026
6cb32be
fix: update @tailwindcss/typography to version 0.5.19
seanmorley15 Mar 17, 2026
65be973
chore: update dependencies in pnpm-lock.yaml
seanmorley15 Mar 17, 2026
d93027a
fix: update appVersion to include the latest build identifier
seanmorley15 Mar 17, 2026
b15724f
fix: enhance authentication fallback for protected media access
seanmorley15 Mar 17, 2026
8fc16d5
feat(auth): add 'mobile-qr' to trailing slash list for URL handling
seanmorley15 Mar 18, 2026
b5da5c1
Translated using Weblate (French)
lesensei Mar 19, 2026
34031d3
Translated using Weblate (Korean)
Mar 22, 2026
79ecf1d
Translated using Weblate (German)
vorbeiei Mar 23, 2026
4046e4c
Translated using Weblate (Swedish)
Mar 23, 2026
bd9ba3f
Added translation using Weblate (Catalan)
mllopart Mar 24, 2026
e03c96c
Translated using Weblate (Catalan)
mllopart Mar 24, 2026
6467f14
Docs: Reorder immich API permissions to natural order (#1086)
stephanzwicknagl Mar 30, 2026
9104809
Translated using Weblate (Turkish)
orhunavcu Mar 26, 2026
b00a04b
Translated using Weblate (Swedish)
bittin Mar 26, 2026
a7aa1ca
Translated using Weblate (German)
vorbeiei Mar 30, 2026
e2a7e18
Add ENABLE_RATE_LIMITS configuration for backend rate limiting
seanmorley15 Apr 1, 2026
2313e8f
Set tabindex to -1 for dropdown menus to improve accessibility
seanmorley15 Apr 1, 2026
770e063
Merge branch 'main' into development
seanmorley15 Apr 4, 2026
1983ba9
Refactor settings page: Simplify HTML structure and improve date form…
seanmorley15 Apr 4, 2026
3b029ce
Update DEFAULT_SCHEMA_CLASS to use OpenAPI schema in REST framework s…
seanmorley15 Apr 4, 2026
bac6139
fix: update error message for key copying and enhance usage instructi…
seanmorley15 Apr 4, 2026
3f874ea
Implement feature X to enhance user experience and fix bug Y in module Z
seanmorley15 Apr 4, 2026
5c40616
feat: add .dockerignore and update Dockerfile for improved build process
seanmorley15 Apr 4, 2026
118f637
fix: add missing svelte-i18n>esbuild override in pnpm-lock and pnpm-w…
seanmorley15 Apr 4, 2026
d8000b6
refactor: update frontend CI workflow for improved quality checks and…
seanmorley15 Apr 4, 2026
d562556
Refactor code structure for improved readability and maintainability
seanmorley15 Apr 4, 2026
4fa6777
fix: add vite>esbuild override in pnpm-lock and pnpm-workspace files
seanmorley15 Apr 4, 2026
6fc5b56
refactor: enhance accessibility and semantics of button elements acro…
seanmorley15 Apr 4, 2026
4944382
feat: update API key deletion confirmation messages in multiple langu…
seanmorley15 Apr 4, 2026
a14b3cd
fix: update djangorestframework version constraint and drf-yasg versi…
seanmorley15 Apr 4, 2026
5c659c3
fix: update appVersion to v0.12.0-main-040426 and refactor button ele…
seanmorley15 Apr 5, 2026
6af1de8
feat: implement developer unlock feature for mobile login in Avatar c…
seanmorley15 Apr 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions .github/workflows/frontend-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,44 @@ permissions:

on:
pull_request:
paths:
- "frontend/**"
- ".github/workflows/frontend-test.yml"
push:
paths:
- "frontend/**"
- ".github/workflows/frontend-test.yml"

jobs:
build:
name: Build and Test Frontend
quality:
name: Frontend Quality Checks
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 10.32.1

- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml

- name: install dependencies
working-directory: frontend
run: npm i
run: pnpm install --frozen-lockfile

- name: run lint
working-directory: frontend
run: pnpm run lint

- name: run svelte check
working-directory: frontend
run: pnpm run check

- name: build frontend
working-directory: frontend
run: npm run build
run: pnpm run build
3 changes: 3 additions & 0 deletions backend/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ PUBLIC_URL='http://127.0.0.1:8000'

DEBUG=True

# Set to True to enable DRF throttling and allauth account rate limits
ENABLE_RATE_LIMITS=False

FRONTEND_URL='http://localhost:3000'

EMAIL_BACKEND='console'
Expand Down
23 changes: 22 additions & 1 deletion backend/server/adventures/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,25 @@ def process_request(self, request):
is_login_or_signup = request.path in ['/auth/browser/v1/auth/login', '/auth/browser/v1/auth/signup']
if is_mobile and is_login_or_signup:
setattr(request, '_dont_enforce_csrf_checks', True)


class DisableCSRFForAPIKeyMiddleware(MiddlewareMixin):
"""Exempt requests carrying an AdventureLog API key from CSRF enforcement.

DRF's own SessionAuthentication is the only built-in class that enforces
CSRF, so this middleware is mainly a safety net for non-DRF views and to
ensure the Django CSRF middleware itself doesn't reject API-key requests
before they reach DRF.
"""

def process_request(self, request):
# Never skip CSRF for requests that also include a Django session.
if settings.SESSION_COOKIE_NAME in request.COOKIES:
return

if request.headers.get('X-API-Key'):
setattr(request, '_dont_enforce_csrf_checks', True)
return

Comment on lines +50 to +58
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This middleware disables CSRF enforcement purely based on the presence of X-API-Key (and similarly for Authorization: Api-Key ...). If a request also carries a valid session cookie, CSRF could be bypassed for session-authenticated Django views. Consider only skipping CSRF when no session cookie is present, or validating the API key before setting _dont_enforce_csrf_checks.

Copilot uses AI. Check for mistakes.
auth_header = request.headers.get('Authorization', '')
if auth_header.lower().startswith('api-key '):
setattr(request, '_dont_enforce_csrf_checks', True)
18 changes: 12 additions & 6 deletions backend/server/main/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
'whitenoise.middleware.WhiteNoiseMiddleware',
'adventures.middleware.XSessionTokenMiddleware',
'adventures.middleware.DisableCSRFForSessionTokenMiddleware',
'adventures.middleware.DisableCSRFForAPIKeyMiddleware',
'adventures.middleware.DisableCSRFForMobileLoginSignup',
'corsheaders.middleware.CorsMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
Expand Down Expand Up @@ -271,6 +272,10 @@ def env(*keys, default=None):
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True # Auto-link by email
SOCIALACCOUNT_AUTO_SIGNUP = True # Allow auto-signup post adapter checks

# Enable or disable app-level rate limiting/throttling globally.
# Defaults to disabled for local/dev convenience.
ENABLE_RATE_LIMITS = getenv('ENABLE_RATE_LIMITS', 'false').lower() == 'true'

FORCE_SOCIALACCOUNT_LOGIN = getenv('FORCE_SOCIALACCOUNT_LOGIN', 'false').lower() == 'true' # When true, only social login is allowed (no password login) and the login page will show only social providers or redirect directly to the first provider if only one is configured.

if getenv('EMAIL_BACKEND', 'console') == 'console':
Expand Down Expand Up @@ -311,23 +316,24 @@ def env(*keys, default=None):
"login": "30/m/ip", # 30 login attempts per minute per IP
"login_failed": "10/m/ip,5/5m/key", # 10 failed logins per minute per IP, 5 per 5 min per user
"confirm_email": "1/3m/key", # 1 email confirmation per 3 minutes per email
}
} if ENABLE_RATE_LIMITS else {}

# ---------------------------------------------------------------------------
# Django REST Framework
# ---------------------------------------------------------------------------
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'users.authentication.APIKeyAuthentication',
'rest_framework.authentication.SessionAuthentication',
),
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema',
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.openapi.AutoSchema',
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.UserRateThrottle',
],
] if ENABLE_RATE_LIMITS else [],
'DEFAULT_THROTTLE_RATES': {
'user': '1000/day',
'image_proxy': '60/minute',
},
'user': '100000/day',
'image_proxy': '1000/minute',
} if ENABLE_RATE_LIMITS else {},
}

if DEBUG:
Expand Down
9 changes: 8 additions & 1 deletion backend/server/main/urls.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from django.urls import include, re_path, path
from django.contrib import admin
from django.views.generic import RedirectView, TemplateView
from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, EnabledSocialProvidersView, DisablePasswordAuthenticationView
from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, EnabledSocialProvidersView, DisablePasswordAuthenticationView, APIKeyListCreateView, APIKeyDetailView, MobileQRCodeView
from .views import get_csrf_token, get_public_url, serve_protected_media
from drf_yasg.views import get_schema_view
from drf_yasg import openapi
Expand Down Expand Up @@ -31,6 +31,13 @@

path('auth/disable-password/', DisablePasswordAuthenticationView.as_view(), name='disable-password-authentication'),

# API key management
path('auth/api-keys/', APIKeyListCreateView.as_view(), name='api-key-list-create'),
path('auth/api-keys/<uuid:pk>/', APIKeyDetailView.as_view(), name='api-key-detail'),

# Mobile QR code for app login
path('auth/mobile-qr/', MobileQRCodeView.as_view(), name='mobile-qr-code'),

path('csrf/', get_csrf_token, name='get_csrf_token'),
path('public-url/', get_public_url, name='get_public_url'),

Expand Down
15 changes: 14 additions & 1 deletion backend/server/main/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,20 @@ def serve_protected_media(request, path):
if any([path.startswith(protected_path) for protected_path in protected_paths]):
image_id = path.split('/')[1]
user = request.user
media_type = path.split('/')[0] + '/'

# Session auth won't populate request.user for API key requests, so
# attempt API key authentication as a fallback.
if not user.is_authenticated:
from users.authentication import APIKeyAuthentication
from rest_framework.exceptions import AuthenticationFailed
try:
result = APIKeyAuthentication().authenticate(request)
if result is not None:
user, _ = result
except AuthenticationFailed:
return HttpResponseForbidden()

media_type = path.split('/')[0] + '/'
if checkFilePermission(image_id, user, media_type):
if settings.DEBUG:
# In debug mode, serve the file directly
Expand Down
6 changes: 3 additions & 3 deletions backend/server/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
Django==5.2.11
djangorestframework>=3.15.2
Django==5.2.12
djangorestframework>=3.15.2,<3.16
django-allauth==0.63.3
django-money==3.5.4
django-invitations==2.1.0
drf-yasg==1.21.4
drf-yasg==1.21.15
django-cors-headers==4.4.0
coreapi==2.3.3
python-dotenv==1.1.0
Expand Down
2 changes: 2 additions & 0 deletions backend/server/users/admin.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.contrib import admin
from allauth.account.decorators import secure_admin_login
from django.contrib.sessions.models import Session
from users.models import APIKey

admin.autodiscover()
admin.site.login = secure_admin_login(admin.site.login)
Expand All @@ -10,4 +11,5 @@ def _session_data(self, obj):
return obj.get_decoded()
list_display = ['session_key', '_session_data', 'expire_date']

admin.site.register(APIKey)
admin.site.register(Session, SessionAdmin)
52 changes: 52 additions & 0 deletions backend/server/users/authentication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""
Custom DRF authentication backend for AdventureLog API keys.

Clients may supply their key via either of these headers:

Authorization: Api-Key al_xxxxxxxxxxxxxxxx...
X-API-Key: al_xxxxxxxxxxxxxxxx...

Session-based CSRF enforcement is performed by DRF's built-in
``SessionAuthentication`` class only. Requests authenticated via this
class are never subject to CSRF checks, which is the correct behaviour
for token-based API access.
"""

from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed


class APIKeyAuthentication(BaseAuthentication):
"""Authenticate a request using an AdventureLog API key."""

def authenticate(self, request):
raw_key = self._extract_key(request)
if raw_key is None:
# Signal to DRF that this scheme was not attempted so other
# authenticators can still run.
return None

from .models import APIKey

api_key = APIKey.authenticate(raw_key)
if api_key is None:
raise AuthenticationFailed("Invalid or expired API key.")

return (api_key.user, api_key)

def authenticate_header(self, request):
return "Api-Key"

@staticmethod
def _extract_key(request) -> str | None:
# Prefer X-API-Key header for simplicity.
key = request.META.get("HTTP_X_API_KEY")
if key:
return key.strip()

# Also accept "Authorization: Api-Key <token>"
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
if auth_header.lower().startswith("api-key "):
return auth_header[8:].strip()

return None
31 changes: 31 additions & 0 deletions backend/server/users/migrations/0007_apikey.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Generated by Django 5.2.11 on 2026-03-16 18:54

import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('users', '0006_customuser_default_currency'),
]

operations = [
migrations.CreateModel(
name='APIKey',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)),
('key_prefix', models.CharField(editable=False, max_length=12)),
('key_hash', models.CharField(editable=False, max_length=64, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
},
),
]
69 changes: 68 additions & 1 deletion backend/server/users/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import hashlib
import secrets
import uuid
from django.contrib.auth.models import AbstractUser
from django.db import models
Expand Down Expand Up @@ -38,4 +40,69 @@ class CustomUser(AbstractUser):


def __str__(self):
return self.username
return self.username


class APIKey(models.Model):
"""
Personal API keys for authenticating programmatic access.

Security design:
- A 32-byte cryptographically random token is generated with the prefix ``al_``.
- Only a SHA-256 hash of the full token is persisted; the plaintext is returned
exactly once at creation time and never stored.
- The first 12 characters of the token are kept as ``key_prefix`` so users can
identify their keys without revealing the secret.
"""

id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(
CustomUser, on_delete=models.CASCADE, related_name='api_keys'
)
name = models.CharField(max_length=100)
key_prefix = models.CharField(max_length=12, editable=False)
key_hash = models.CharField(max_length=64, unique=True, editable=False)
created_at = models.DateTimeField(auto_now_add=True)
last_used_at = models.DateTimeField(null=True, blank=True)

class Meta:
ordering = ['-created_at']

def __str__(self):
return f"{self.user.username} – {self.name} ({self.key_prefix}…)"

@classmethod
def generate(cls, user, name: str) -> tuple['APIKey', str]:
"""
Create a new APIKey for *user* with the given *name*.

Returns a ``(instance, raw_key)`` tuple. The raw key is shown to the
user once and must never be stored anywhere after that.
"""
raw_key = f"al_{secrets.token_urlsafe(32)}"
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
key_prefix = raw_key[:12]
instance = cls.objects.create(
user=user,
name=name,
key_prefix=key_prefix,
key_hash=key_hash,
)
return instance, raw_key

@classmethod
def authenticate(cls, raw_key: str):
"""
Look up an APIKey by its raw value.

Returns the matching ``APIKey`` instance (updating ``last_used_at``) or
``None`` if not found.
"""
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
try:
api_key = cls.objects.select_related('user').get(key_hash=key_hash)
except cls.DoesNotExist:
return None
from django.utils import timezone
cls.objects.filter(pk=api_key.pk).update(last_used_at=timezone.now())
return api_key
Loading
Loading