diff --git a/marietje/marietje/settings/base.py b/marietje/marietje/settings/base.py index 412c69e..05e90c2 100644 --- a/marietje/marietje/settings/base.py +++ b/marietje/marietje/settings/base.py @@ -129,6 +129,7 @@ OAUTH2_PROVIDER = { "write": "Authenticated write access to the website", }, } +OAUTH2_PROVIDER_APPLICATION_MODEL = "oauth2_provider.Application" STATIC_ROOT = os.path.join(BASE_DIR, 'static') STATIC_URL = '/static/' @@ -163,3 +164,5 @@ TRUSTED_IP_RANGES = [ ] DEFAULT_QUEUE = 1 + +OAUTH_2_APPLICATIONS_WITH_GAIN_AND_PEAK_PERMISSION = [2] diff --git a/marietje/marietje/urls.py b/marietje/marietje/urls.py index 4c4291d..2d4ab93 100644 --- a/marietje/marietje/urls.py +++ b/marietje/marietje/urls.py @@ -38,6 +38,5 @@ urlpatterns = [ path("beeldscherm/", partial(render, template_name="marietje/beeldscherm.html"), name="beeldscherm"), path("songs/", include(("songs.urls", "songs"), namespace="songs")), path("api/", include("marietje.api.urls")), - path("playerapi/", include(("playerapi.urls", "playerapi"), namespace="playerapi")), path("stats/", include(("stats.urls", "stats"), namespace="stats")), ] diff --git a/marietje/playerapi/__init__.py b/marietje/playerapi/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/marietje/playerapi/apps.py b/marietje/playerapi/apps.py deleted file mode 100644 index 84ff933..0000000 --- a/marietje/playerapi/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class PlayerapiConfig(AppConfig): - name = "playerapi" diff --git a/marietje/playerapi/decorators.py b/marietje/playerapi/decorators.py deleted file mode 100644 index 3afd847..0000000 --- a/marietje/playerapi/decorators.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.contrib.auth.hashers import check_password -from django.shortcuts import get_object_or_404 -from django.http import HttpResponseForbidden - -from queues.models import Queue - - -def token_required(function): - def _dec(request): - queue = get_object_or_404(Queue, id=request.POST.get("queue")) - if not check_password(request.POST.get("player_token"), queue.player_token): - return HttpResponseForbidden() - return function(request) - - return _dec diff --git a/marietje/playerapi/migrations/__init__.py b/marietje/playerapi/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/marietje/playerapi/urls.py b/marietje/playerapi/urls.py deleted file mode 100644 index 6e439c2..0000000 --- a/marietje/playerapi/urls.py +++ /dev/null @@ -1,12 +0,0 @@ -from django.urls import path - -from . import views - -app_name = "playerapi" - -urlpatterns = [ - path("queue/", views.queue), - path("play/", views.play), - path("next/", views.next), - path("analysed/", views.analysed), -] diff --git a/marietje/playerapi/views.py b/marietje/playerapi/views.py deleted file mode 100644 index ef6958b..0000000 --- a/marietje/playerapi/views.py +++ /dev/null @@ -1,70 +0,0 @@ -from django.utils import timezone - -from django.http import JsonResponse -from django.shortcuts import get_object_or_404 -from django.views.decorators.csrf import csrf_exempt - -from marietje.utils import playlist_song_to_dict -from queues.models import Queue -from songs.models import Song - -from .decorators import token_required - - -@csrf_exempt -@token_required -def queue(request): - current_queue = get_object_or_404(Queue, id=request.POST.get("queue")) - commands = current_queue.queuecommand_set.all() - response = JsonResponse( - { - "current_song": playlist_song_to_dict( - current_queue.current_song(), include_hash=True, include_replaygain=True - ), - "queue": [ - playlist_song_to_dict(playlist_song, include_hash=True, include_replaygain=True) - for playlist_song in current_queue.queue()[:1] - ], - "commands": [command.command for command in commands], - } - ) - for command in commands: - command.delete() - - return response - - -@csrf_exempt -@token_required -def play(request): - current_queue = get_object_or_404(Queue, id=request.POST.get("queue")) - current_queue.started_at = timezone.now() - current_queue.save() - current_song = current_queue.current_song() - current_song.played_at = current_queue.started_at - current_song.save() - - return JsonResponse({}) - - -# pylint: disable=redefined-builtin -@csrf_exempt -@token_required -def next(request): - current_queue = get_object_or_404(Queue, id=request.POST.get("queue")) - player_song = current_queue.current_song() - player_song.state = 2 - player_song.save() - return JsonResponse({}) - - -@csrf_exempt -@token_required -def analysed(request): - song = get_object_or_404(Song, id=request.POST.get("song")) - if "gain" in request.POST: - song.rg_gain = request.POST.get("gain") - if "peak" in request.POST: - song.rg_peak = request.POST.get("peak") - song.save() - return JsonResponse({}) diff --git a/marietje/queues/api/v1/serializers.py b/marietje/queues/api/v1/serializers.py index 0a06494..0c0a8c3 100644 --- a/marietje/queues/api/v1/serializers.py +++ b/marietje/queues/api/v1/serializers.py @@ -3,7 +3,7 @@ import time from rest_framework import serializers from marietje.api.v1.serializers import UserRelatedFieldSerializer -from queues.models import Queue, Playlist, PlaylistSong +from queues.models import Queue, Playlist, PlaylistSong, QueueCommand from songs.api.v1.serializers import SongSerializer @@ -28,8 +28,8 @@ class PlaylistSerializer(serializers.ModelSerializer): class QueueSerializer(serializers.ModelSerializer): - current_song = serializers.SerializerMethodField() - queue = serializers.SerializerMethodField() + current_song = serializers.SerializerMethodField(read_only=True) + queue = serializers.SerializerMethodField(read_only=True) def get_current_song(self, queue): return PlaylistSongSerializer(queue.current_song()).data @@ -48,3 +48,18 @@ class QueueSerializer(serializers.ModelSerializer): "queue", "started_at", ] + read_only_fiels = [ + "id", + "current_song", + "queue", + ] + + +class QueueCommandSerializer(serializers.ModelSerializer): + class Meta: + model = QueueCommand + fields = [ + "id", + "queue", + "command", + ] diff --git a/marietje/queues/api/v1/urls.py b/marietje/queues/api/v1/urls.py index 1ef7b94..7264cfd 100644 --- a/marietje/queues/api/v1/urls.py +++ b/marietje/queues/api/v1/urls.py @@ -11,10 +11,14 @@ from queues.api.v1.views import ( QueueVolumeDownAPIView, QueueVolumeUpAPIView, QueueMuteAPIView, + QueueCommandListAPIView, + QueueCommandDestroyAPIView, + QueueUpdateAPIView, ) urlpatterns = [ path("current/", QueueAPIView.as_view(), name="queue_current"), + path("/", QueueUpdateAPIView.as_view(), name="queue_update"), path("current/skip/", QueueSkipAPIView.as_view(), name="queue_skip"), path("current/request/", QueueRequestAPIView.as_view(), name="queue_request"), path("current/volume-down/", QueueVolumeDownAPIView.as_view(), name="queue_volume_down"), @@ -24,4 +28,6 @@ urlpatterns = [ path("playlists//", PlaylistRetrieveAPIView.as_view(), name="playlist_retrieve"), path("playlist-song//move-down/", PlaylistSongMoveDownAPIView.as_view(), name="playlist_song_move_down"), path("playlist-song//cancel/", PlaylistSongCancelAPIView.as_view(), name="playlist_song_cancel"), + path("/commands/", QueueCommandListAPIView.as_view(), name="queue_command_list"), + path("commands//", QueueCommandDestroyAPIView.as_view(), name="queue_command_destroy"), ] diff --git a/marietje/queues/api/v1/views.py b/marietje/queues/api/v1/views.py index 446cfd2..8bc0859 100644 --- a/marietje/queues/api/v1/views.py +++ b/marietje/queues/api/v1/views.py @@ -1,4 +1,13 @@ -from rest_framework.generics import ListAPIView, RetrieveAPIView, get_object_or_404, CreateAPIView, DestroyAPIView +from oauth2_provider.views.mixins import ClientProtectedResourceMixin +from rest_framework import status, mixins +from rest_framework.generics import ( + ListAPIView, + RetrieveAPIView, + get_object_or_404, + CreateAPIView, + DestroyAPIView, + UpdateAPIView, +) from rest_framework.views import APIView from rest_framework.response import Response @@ -6,9 +15,14 @@ from marietje.api.openapi import CustomAutoSchema from marietje.api.permissions import IsAuthenticatedOrTokenHasScopeForMethod from django.http import Http404 -from queues.api.v1.serializers import PlaylistSerializer, QueueSerializer, PlaylistSongSerializer +from queues.api.v1.serializers import ( + PlaylistSerializer, + QueueSerializer, + PlaylistSongSerializer, + QueueCommandSerializer, +) from queues.exceptions import RequestException -from queues.models import Playlist, PlaylistSong, QueueCommand +from queues.models import Playlist, PlaylistSong, QueueCommand, Queue from queues.services import get_user_or_default_queue from songs.counters import request_counter from songs.models import Song @@ -47,13 +61,58 @@ class QueueAPIView(APIView): } ) - def get(self, request, **kwargs): - queue = get_user_or_default_queue(request) + def get_object(self): + queue = get_user_or_default_queue(self.request) if queue is None: raise Http404() + return queue + + def get(self, request, **kwargs): + queue = self.get_object() return Response(status=200, data=self.serializer_class(queue).data) +class QueueUpdateAPIView(ClientProtectedResourceMixin, UpdateAPIView): + serializer_class = QueueSerializer + queryset = Queue.objects.all() + permission_classes = [IsAuthenticatedOrTokenHasScopeForMethod] + required_scopes_for_method = {"PUT": ["write"], "PATCH": ["write"]} + schema = CustomAutoSchema( + response_schema={ + "type": "object", + "properties": { + "id": {"type": "int", "example": 1}, + "name": {"type": "string", "example": "string"}, + "playlist": {"type": "int", "example": 1}, + "random_playlist": {"type": "int", "example": 1}, + "current_song": {"$ref": "#/components/schemas/PlaylistSong"}, + "queue": {"type": "array", "items": {"$ref": "#/components/schemas/PlaylistSong"}}, + "started_at": {"type": "string", "format": "date-time", "nullable": True}, + }, + } + ) + + def put(self, request, **kwargs): + queue = self.get_object() + if queue.oauth_client is None or queue.oauth_client != request.auth.application: + return Response( + {"detail": "Unauthorized"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + else: + return super(QueueUpdateAPIView, self).put(request, **kwargs) + + def patch(self, request, **kwargs): + queue = self.get_object() + if queue.oauth_client is None or queue.oauth_client != request.auth.application: + return Response( + {"detail": "Unauthorized"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + else: + return super(QueueUpdateAPIView, self).patch(request, **kwargs) + + class QueueSkipAPIView(APIView): serializer_class = QueueSerializer permission_classes = [IsAuthenticatedOrTokenHasScopeForMethod] @@ -80,12 +139,14 @@ class QueueSkipAPIView(APIView): if queue is None: return Response(status=404) - playlist_song = request.user.queue.current_song() - if ( - request.user is not None - and playlist_song.user != request.user - and not request.user.has_perm("queues.can_skip") - ): + playlist_song = queue.current_song() + if request.user is not None: + if playlist_song.user != request.user and not request.user.has_perm("queues.can_skip"): + return Response(status=403) + elif request.auth is not None: + if queue.oauth_client is None or request.auth.application != queue.oauth_client: + return Response(status=403) + else: return Response(status=403) playlist_song.state = 2 @@ -260,3 +321,41 @@ class QueueMuteAPIView(APIView): return Response(status=403) QueueCommand.objects.create(queue=queue, command="mute") return Response(status=200, data=self.serializer_class(queue).data) + + +class QueueCommandListAPIView(ClientProtectedResourceMixin, ListAPIView): + serializer_class = QueueCommandSerializer + queryset = QueueCommand.objects.all() + permission_classes = [IsAuthenticatedOrTokenHasScopeForMethod] + required_scopes_for_method = {"GET": ["read"]} + + def get_queryset(self): + queue = get_object_or_404(Queue, pk=self.kwargs.get("pk")) + self.queryset.filter(queue=queue) + + def get(self, request, **kwargs): + queue = get_object_or_404(Queue, pk=kwargs.get("pk")) + if queue.oauth_client is None or queue.oauth_client != request.auth.application: + return Response( + {"detail": "Unauthorized"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + else: + return super(QueueCommandListAPIView, self).get(request, **kwargs) + + +class QueueCommandDestroyAPIView(ClientProtectedResourceMixin, DestroyAPIView): + serializer_class = QueueCommandSerializer + queryset = QueueCommand.objects.all() + permission_classes = [IsAuthenticatedOrTokenHasScopeForMethod] + required_scopes_for_method = {"DELETE": ["write"]} + + def delete(self, request, **kwargs): + queue_command = self.get_object() + if queue_command.queue.oauth_client is None or queue_command.queue.oauth_client != request.auth.application: + return Response( + {"detail": "Unauthorized"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + else: + return super(QueueCommandDestroyAPIView, self).delete(request, **kwargs) diff --git a/marietje/queues/migrations/0012_queue_oauth_client.py b/marietje/queues/migrations/0012_queue_oauth_client.py new file mode 100644 index 0000000..2171467 --- /dev/null +++ b/marietje/queues/migrations/0012_queue_oauth_client.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.6 on 2023-11-05 14:33 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ("queues", "0011_alter_playlistsong_playlist"), + ] + + operations = [ + migrations.AddField( + model_name="queue", + name="oauth_client", + field=models.ForeignKey( + blank=True, + help_text="The OAuth2 client that may read and write commands for this Queue.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="queues", + to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL, + ), + ), + ] diff --git a/marietje/queues/models.py b/marietje/queues/models.py index 76d2388..a750d9d 100644 --- a/marietje/queues/models.py +++ b/marietje/queues/models.py @@ -2,6 +2,7 @@ from django.db import models from django.db.models import Q from django.conf import settings from django.utils import timezone +from oauth2_provider.models import Application from queues.exceptions import RequestException from songs.models import Song @@ -81,6 +82,14 @@ class Queue(models.Model): ) started_at = models.DateTimeField(blank=True, null=True) player_token = models.TextField(blank=True, null=True) + oauth_client = models.ForeignKey( + Application, + on_delete=models.SET_NULL, + help_text="The OAuth2 client that may read and write commands for this Queue.", + related_name="queues", + null=True, + blank=True, + ) def get_songs(self): self.fill_random_queue() diff --git a/marietje/songs/api/v1/serializers.py b/marietje/songs/api/v1/serializers.py index 61020fa..379db12 100644 --- a/marietje/songs/api/v1/serializers.py +++ b/marietje/songs/api/v1/serializers.py @@ -5,11 +5,12 @@ from songs.models import Song, ReportNote class SongSerializer(serializers.ModelSerializer): - user = UserRelatedFieldSerializer() + user = UserRelatedFieldSerializer(read_only=True) class Meta: model = Song fields = ["id", "artist", "title", "duration", "hash", "user", "rg_gain", "rg_peak"] + read_only_fields = ["id", "duration", "hash", "user"] class ReportNoteSerializer(serializers.ModelSerializer): diff --git a/marietje/songs/api/v1/urls.py b/marietje/songs/api/v1/urls.py index 5e27729..b0b6c21 100644 --- a/marietje/songs/api/v1/urls.py +++ b/marietje/songs/api/v1/urls.py @@ -1,10 +1,10 @@ from django.urls import path -from .views import SongsListAPIView, SongRetrieveAPIView, SongUploadAPIView, ReportNoteCreateAPIView +from .views import SongsListAPIView, SongRetrieveUpdateAPIView, SongUploadAPIView, ReportNoteCreateAPIView urlpatterns = [ path("", SongsListAPIView.as_view(), name="song_list"), - path("/", SongRetrieveAPIView.as_view(), name="song_retrieve"), + path("/", SongRetrieveUpdateAPIView.as_view(), name="song_retrieve_update"), path("report-notes/", ReportNoteCreateAPIView.as_view(), name="report_note_create"), path("upload/", SongUploadAPIView.as_view(), name="song_upload"), ] diff --git a/marietje/songs/api/v1/views.py b/marietje/songs/api/v1/views.py index 63c78a4..c44de3e 100644 --- a/marietje/songs/api/v1/views.py +++ b/marietje/songs/api/v1/views.py @@ -1,6 +1,7 @@ +from django.conf import settings from django_filters.rest_framework import DjangoFilterBackend -from rest_framework.generics import ListAPIView, RetrieveAPIView, CreateAPIView -from rest_framework import filters +from rest_framework.generics import ListAPIView, RetrieveUpdateAPIView, CreateAPIView +from rest_framework import filters, status from rest_framework.views import APIView from rest_framework.response import Response @@ -27,11 +28,35 @@ class SongsListAPIView(ListAPIView): ] -class SongRetrieveAPIView(RetrieveAPIView): +class SongRetrieveUpdateAPIView(RetrieveUpdateAPIView): serializer_class = SongSerializer queryset = Song.objects.all() permission_classes = [IsAuthenticatedOrTokenHasScopeForMethod] - required_scopes_for_method = {"GET": ["read"]} + required_scopes_for_method = {"GET": ["read"], "PUT": ["write"], "PATCH": ["write"]} + + def put(self, request, **kwargs): + if ( + request.auth is None + or request.auth.application.id not in settings.OAUTH_2_APPLICATIONS_WITH_GAIN_AND_PEAK_PERMISSION + ): + return Response( + {"detail": "Unauthorized"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + return super(SongRetrieveUpdateAPIView, self).put(request, **kwargs) + + def patch(self, request, **kwargs): + if ( + request.auth is None + or request.auth.application.id not in settings.OAUTH_2_APPLICATIONS_WITH_GAIN_AND_PEAK_PERMISSION + ): + return Response( + {"detail": "Unauthorized"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + return super(SongRetrieveUpdateAPIView, self).patch(request, **kwargs) class ReportNoteCreateAPIView(CreateAPIView):