1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-19 12:59:36 +02:00

feat: implement protected media serving and permission checks for adventure images

This commit is contained in:
Sean Morley 2025-01-18 17:03:03 -05:00
parent f10e171a8e
commit 433599dc20
7 changed files with 130 additions and 55 deletions

View file

@ -19,7 +19,7 @@ http {
} }
server { server {
listen 80; # NGINX listens on port 80 inside the container listen 80;
server_name localhost; server_name localhost;
location / { location / {
@ -34,8 +34,12 @@ http {
alias /code/staticfiles/; # Serve static files directly alias /code/staticfiles/; # Serve static files directly
} }
location /media/ { # Serve protected media files with X-Accel-Redirect
alias /code/media/; # Serve media files directly location /protectedMedia/ {
internal; # Only internal requests are allowed
alias /code/media/; # This should match Django MEDIA_ROOT
try_files $uri =404; # Return a 404 if the file doesn't exist
} }
} }
} }

View file

@ -0,0 +1,33 @@
from adventures.models import AdventureImage
def checkAdventureImagePermission(imageId, user):
"""
Checks if the given user has permission to access the specified adventure image.
Args:
imageId (str): The ID of the image to check permissions for.
user (User): The user object to check permissions against.
Returns:
bool: True if the user has permission to access the image, False otherwise.
Raises:
AdventureImage.DoesNotExist: If the image with the specified ID does not exist.
"""
try:
# Construct the full relative path to match the database field
image_path = f"images/{imageId}"
# Fetch the AdventureImage object
adventure = AdventureImage.objects.get(image=image_path).adventure
if adventure.is_public:
return True
elif adventure.user_id == user:
return True
elif adventure.collection:
if adventure.collection.shared_with.filter(id=user.id).exists():
return True
else:
return False
except AdventureImage.DoesNotExist:
print('No image')
return False

View file

@ -157,7 +157,7 @@ STATIC_ROOT = BASE_DIR / "staticfiles"
STATIC_URL = '/static/' STATIC_URL = '/static/'
MEDIA_URL = '/media/' MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media' MEDIA_ROOT = BASE_DIR / 'media' # This path must match the NGINX root
STATICFILES_DIRS = [BASE_DIR / 'static'] STATICFILES_DIRS = [BASE_DIR / 'static']
STORAGES = { STORAGES = {

View file

@ -1,12 +1,9 @@
from django.urls import include, re_path, path from django.urls import include, re_path, path
from django.contrib import admin from django.contrib import admin
from django.views.generic import RedirectView, TemplateView from django.views.generic import RedirectView, TemplateView
from django.conf import settings
from django.conf.urls.static import static
from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, EnabledSocialProvidersView from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, EnabledSocialProvidersView
from .views import get_csrf_token, get_public_url from .views import get_csrf_token, get_public_url, serve_protected_media
from drf_yasg.views import get_schema_view from drf_yasg.views import get_schema_view
from drf_yasg import openapi from drf_yasg import openapi
schema_view = get_schema_view( schema_view = get_schema_view(
@ -20,6 +17,9 @@ urlpatterns = [
path('api/', include('worldtravel.urls')), path('api/', include('worldtravel.urls')),
path("_allauth/", include("allauth.headless.urls")), path("_allauth/", include("allauth.headless.urls")),
# Serve protected media files
re_path(r'^media/(?P<path>.*)$', serve_protected_media, name='serve-protected-media'),
path('auth/is-registration-disabled/', IsRegistrationDisabled.as_view(), name='is_registration_disabled'), path('auth/is-registration-disabled/', IsRegistrationDisabled.as_view(), name='is_registration_disabled'),
path('auth/users/', PublicUserListView.as_view(), name='public-user-list'), path('auth/users/', PublicUserListView.as_view(), name='public-user-list'),
path('auth/user/<uuid:user_id>/', PublicUserDetailView.as_view(), name='public-user-detail'), path('auth/user/<uuid:user_id>/', PublicUserDetailView.as_view(), name='public-user-detail'),
@ -45,5 +45,4 @@ urlpatterns = [
path("api/integrations/", include("integrations.urls")), path("api/integrations/", include("integrations.urls")),
# Include the API endpoints: # Include the API endpoints:
]
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View file

@ -1,6 +1,11 @@
from django.http import JsonResponse from django.http import JsonResponse
from django.middleware.csrf import get_token from django.middleware.csrf import get_token
from os import getenv from os import getenv
from django.conf import settings
from django.http import HttpResponse, HttpResponseForbidden
from django.contrib.auth.decorators import login_required
from django.views.static import serve
from adventures.utils.check_adventure_image_permisison import checkAdventureImagePermission
def get_csrf_token(request): def get_csrf_token(request):
csrf_token = get_token(request) csrf_token = get_token(request)
@ -8,3 +13,26 @@ def get_csrf_token(request):
def get_public_url(request): def get_public_url(request):
return JsonResponse({'PUBLIC_URL': getenv('PUBLIC_URL')}) return JsonResponse({'PUBLIC_URL': getenv('PUBLIC_URL')})
def serve_protected_media(request, path):
if path.startswith('images/'):
image_id = path.split('/')[1]
user = request.user
if checkAdventureImagePermission(image_id, user):
if settings.DEBUG:
# In debug mode, serve the file directly
return serve(request, path, document_root=settings.MEDIA_ROOT)
else:
# In production, use X-Accel-Redirect
response = HttpResponse()
response['Content-Type'] = ''
response['X-Accel-Redirect'] = '/protectedMedia/' + path
return response
else:
return HttpResponseForbidden()
else:
response = HttpResponse()
response['Content-Type'] = ''
response['X-Accel-Redirect'] = '/protectedMedia/' + path
return response

View file

@ -120,27 +120,34 @@
</li> </li>
{/if} {/if}
<form class="flex gap-2"> {#if data.user}
<label class="input input-bordered flex items-center gap-2"> <form class="flex gap-2">
<input type="text" bind:value={query} class="grow" placeholder={$t('navbar.search')} /> <label class="input input-bordered flex items-center gap-2">
<input
<svg type="text"
xmlns="http://www.w3.org/2000/svg" bind:value={query}
viewBox="0 0 16 16" class="grow"
fill="currentColor" placeholder={$t('navbar.search')}
class="h-4 w-4 opacity-70"
>
<path
fill-rule="evenodd"
d="M9.965 11.026a5 5 0 1 1 1.06-1.06l2.755 2.754a.75.75 0 1 1-1.06 1.06l-2.755-2.754ZM10.5 7a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Z"
clip-rule="evenodd"
/> />
</svg>
</label> <svg
<button on:click={searchGo} type="submit" class="btn btn-primary" xmlns="http://www.w3.org/2000/svg"
>{$t('navbar.search')}</button viewBox="0 0 16 16"
> fill="currentColor"
</form> class="h-4 w-4 opacity-70"
>
<path
fill-rule="evenodd"
d="M9.965 11.026a5 5 0 1 1 1.06-1.06l2.755 2.754a.75.75 0 1 1-1.06 1.06l-2.755-2.754ZM10.5 7a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Z"
clip-rule="evenodd"
/>
</svg>
</label>
<button on:click={searchGo} type="submit" class="btn btn-primary"
>{$t('navbar.search')}</button
>
</form>
{/if}
</ul> </ul>
</div> </div>
<a class="btn btn-ghost text-xl" href="/" <a class="btn btn-ghost text-xl" href="/"
@ -190,27 +197,29 @@
</li> </li>
{/if} {/if}
<form class="flex gap-2"> {#if data.user}
<label class="input input-bordered flex items-center gap-2"> <form class="flex gap-2">
<input type="text" bind:value={query} class="grow" placeholder={$t('navbar.search')} /> <label class="input input-bordered flex items-center gap-2">
<input type="text" bind:value={query} class="grow" placeholder={$t('navbar.search')} />
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16" viewBox="0 0 16 16"
fill="currentColor" fill="currentColor"
class="h-4 w-4 opacity-70" class="h-4 w-4 opacity-70"
>
<path
fill-rule="evenodd"
d="M9.965 11.026a5 5 0 1 1 1.06-1.06l2.755 2.754a.75.75 0 1 1-1.06 1.06l-2.755-2.754ZM10.5 7a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Z"
clip-rule="evenodd"
/>
</svg>
</label>
<button on:click={searchGo} type="submit" class="btn btn-neutral"
>{$t('navbar.search')}</button
> >
<path </form>
fill-rule="evenodd" {/if}
d="M9.965 11.026a5 5 0 1 1 1.06-1.06l2.755 2.754a.75.75 0 1 1-1.06 1.06l-2.755-2.754ZM10.5 7a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0Z"
clip-rule="evenodd"
/>
</svg>
</label>
<button on:click={searchGo} type="submit" class="btn btn-neutral"
>{$t('navbar.search')}</button
>
</form>
</ul> </ul>
</div> </div>
<div class="navbar-end"> <div class="navbar-end">

View file

@ -1,12 +1,14 @@
import type { Adventure, OpenStreetMapPlace } from '$lib/types'; import { redirect } from '@sveltejs/kit';
import { fail } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { appVersion } from '$lib/config';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL']; const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000'; const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const load = (async (event) => { export const load = (async (event) => {
if (!event.locals.user) {
return redirect(302, '/login');
}
const query = event.url.searchParams.get('query'); const query = event.url.searchParams.get('query');
if (!query) { if (!query) {