Change player to OAuth protocol

This commit is contained in:
Lars van Rhijn
2023-11-12 09:33:19 +01:00
parent 66ac1076d3
commit d55ff6c8c6
16 changed files with 206 additions and 124 deletions

View File

@ -129,6 +129,7 @@ OAUTH2_PROVIDER = {
"write": "Authenticated write access to the website", "write": "Authenticated write access to the website",
}, },
} }
OAUTH2_PROVIDER_APPLICATION_MODEL = "oauth2_provider.Application"
STATIC_ROOT = os.path.join(BASE_DIR, 'static') STATIC_ROOT = os.path.join(BASE_DIR, 'static')
STATIC_URL = '/static/' STATIC_URL = '/static/'
@ -163,3 +164,5 @@ TRUSTED_IP_RANGES = [
] ]
DEFAULT_QUEUE = 1 DEFAULT_QUEUE = 1
OAUTH_2_APPLICATIONS_WITH_GAIN_AND_PEAK_PERMISSION = [2]

View File

@ -38,6 +38,5 @@ urlpatterns = [
path("beeldscherm/", partial(render, template_name="marietje/beeldscherm.html"), name="beeldscherm"), path("beeldscherm/", partial(render, template_name="marietje/beeldscherm.html"), name="beeldscherm"),
path("songs/", include(("songs.urls", "songs"), namespace="songs")), path("songs/", include(("songs.urls", "songs"), namespace="songs")),
path("api/", include("marietje.api.urls")), path("api/", include("marietje.api.urls")),
path("playerapi/", include(("playerapi.urls", "playerapi"), namespace="playerapi")),
path("stats/", include(("stats.urls", "stats"), namespace="stats")), path("stats/", include(("stats.urls", "stats"), namespace="stats")),
] ]

View File

@ -1,5 +0,0 @@
from django.apps import AppConfig
class PlayerapiConfig(AppConfig):
name = "playerapi"

View File

@ -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

View File

@ -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),
]

View File

@ -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({})

View File

@ -3,7 +3,7 @@ import time
from rest_framework import serializers from rest_framework import serializers
from marietje.api.v1.serializers import UserRelatedFieldSerializer 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 from songs.api.v1.serializers import SongSerializer
@ -28,8 +28,8 @@ class PlaylistSerializer(serializers.ModelSerializer):
class QueueSerializer(serializers.ModelSerializer): class QueueSerializer(serializers.ModelSerializer):
current_song = serializers.SerializerMethodField() current_song = serializers.SerializerMethodField(read_only=True)
queue = serializers.SerializerMethodField() queue = serializers.SerializerMethodField(read_only=True)
def get_current_song(self, queue): def get_current_song(self, queue):
return PlaylistSongSerializer(queue.current_song()).data return PlaylistSongSerializer(queue.current_song()).data
@ -48,3 +48,18 @@ class QueueSerializer(serializers.ModelSerializer):
"queue", "queue",
"started_at", "started_at",
] ]
read_only_fiels = [
"id",
"current_song",
"queue",
]
class QueueCommandSerializer(serializers.ModelSerializer):
class Meta:
model = QueueCommand
fields = [
"id",
"queue",
"command",
]

View File

@ -11,10 +11,14 @@ from queues.api.v1.views import (
QueueVolumeDownAPIView, QueueVolumeDownAPIView,
QueueVolumeUpAPIView, QueueVolumeUpAPIView,
QueueMuteAPIView, QueueMuteAPIView,
QueueCommandListAPIView,
QueueCommandDestroyAPIView,
QueueUpdateAPIView,
) )
urlpatterns = [ urlpatterns = [
path("current/", QueueAPIView.as_view(), name="queue_current"), path("current/", QueueAPIView.as_view(), name="queue_current"),
path("<int:pk>/", QueueUpdateAPIView.as_view(), name="queue_update"),
path("current/skip/", QueueSkipAPIView.as_view(), name="queue_skip"), path("current/skip/", QueueSkipAPIView.as_view(), name="queue_skip"),
path("current/request/", QueueRequestAPIView.as_view(), name="queue_request"), path("current/request/", QueueRequestAPIView.as_view(), name="queue_request"),
path("current/volume-down/", QueueVolumeDownAPIView.as_view(), name="queue_volume_down"), path("current/volume-down/", QueueVolumeDownAPIView.as_view(), name="queue_volume_down"),
@ -24,4 +28,6 @@ urlpatterns = [
path("playlists/<int:pk>/", PlaylistRetrieveAPIView.as_view(), name="playlist_retrieve"), path("playlists/<int:pk>/", PlaylistRetrieveAPIView.as_view(), name="playlist_retrieve"),
path("playlist-song/<int:id>/move-down/", PlaylistSongMoveDownAPIView.as_view(), name="playlist_song_move_down"), path("playlist-song/<int:id>/move-down/", PlaylistSongMoveDownAPIView.as_view(), name="playlist_song_move_down"),
path("playlist-song/<int:id>/cancel/", PlaylistSongCancelAPIView.as_view(), name="playlist_song_cancel"), path("playlist-song/<int:id>/cancel/", PlaylistSongCancelAPIView.as_view(), name="playlist_song_cancel"),
path("<int:pk>/commands/", QueueCommandListAPIView.as_view(), name="queue_command_list"),
path("commands/<int:pk>/", QueueCommandDestroyAPIView.as_view(), name="queue_command_destroy"),
] ]

View File

@ -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.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
@ -6,9 +15,14 @@ from marietje.api.openapi import CustomAutoSchema
from marietje.api.permissions import IsAuthenticatedOrTokenHasScopeForMethod from marietje.api.permissions import IsAuthenticatedOrTokenHasScopeForMethod
from django.http import Http404 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.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 queues.services import get_user_or_default_queue
from songs.counters import request_counter from songs.counters import request_counter
from songs.models import Song from songs.models import Song
@ -47,13 +61,58 @@ class QueueAPIView(APIView):
} }
) )
def get(self, request, **kwargs): def get_object(self):
queue = get_user_or_default_queue(request) queue = get_user_or_default_queue(self.request)
if queue is None: if queue is None:
raise Http404() raise Http404()
return queue
def get(self, request, **kwargs):
queue = self.get_object()
return Response(status=200, data=self.serializer_class(queue).data) 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): class QueueSkipAPIView(APIView):
serializer_class = QueueSerializer serializer_class = QueueSerializer
permission_classes = [IsAuthenticatedOrTokenHasScopeForMethod] permission_classes = [IsAuthenticatedOrTokenHasScopeForMethod]
@ -80,12 +139,14 @@ class QueueSkipAPIView(APIView):
if queue is None: if queue is None:
return Response(status=404) return Response(status=404)
playlist_song = request.user.queue.current_song() playlist_song = queue.current_song()
if ( if request.user is not None:
request.user is not None if playlist_song.user != request.user and not request.user.has_perm("queues.can_skip"):
and playlist_song.user != request.user return Response(status=403)
and not request.user.has_perm("queues.can_skip") 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) return Response(status=403)
playlist_song.state = 2 playlist_song.state = 2
@ -260,3 +321,41 @@ class QueueMuteAPIView(APIView):
return Response(status=403) return Response(status=403)
QueueCommand.objects.create(queue=queue, command="mute") QueueCommand.objects.create(queue=queue, command="mute")
return Response(status=200, data=self.serializer_class(queue).data) 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)

View File

@ -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,
),
),
]

View File

@ -2,6 +2,7 @@ from django.db import models
from django.db.models import Q from django.db.models import Q
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from oauth2_provider.models import Application
from queues.exceptions import RequestException from queues.exceptions import RequestException
from songs.models import Song from songs.models import Song
@ -81,6 +82,14 @@ class Queue(models.Model):
) )
started_at = models.DateTimeField(blank=True, null=True) started_at = models.DateTimeField(blank=True, null=True)
player_token = models.TextField(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): def get_songs(self):
self.fill_random_queue() self.fill_random_queue()

View File

@ -5,11 +5,12 @@ from songs.models import Song, ReportNote
class SongSerializer(serializers.ModelSerializer): class SongSerializer(serializers.ModelSerializer):
user = UserRelatedFieldSerializer() user = UserRelatedFieldSerializer(read_only=True)
class Meta: class Meta:
model = Song model = Song
fields = ["id", "artist", "title", "duration", "hash", "user", "rg_gain", "rg_peak"] fields = ["id", "artist", "title", "duration", "hash", "user", "rg_gain", "rg_peak"]
read_only_fields = ["id", "duration", "hash", "user"]
class ReportNoteSerializer(serializers.ModelSerializer): class ReportNoteSerializer(serializers.ModelSerializer):

View File

@ -1,10 +1,10 @@
from django.urls import path from django.urls import path
from .views import SongsListAPIView, SongRetrieveAPIView, SongUploadAPIView, ReportNoteCreateAPIView from .views import SongsListAPIView, SongRetrieveUpdateAPIView, SongUploadAPIView, ReportNoteCreateAPIView
urlpatterns = [ urlpatterns = [
path("", SongsListAPIView.as_view(), name="song_list"), path("", SongsListAPIView.as_view(), name="song_list"),
path("<int:pk>/", SongRetrieveAPIView.as_view(), name="song_retrieve"), path("<int:pk>/", SongRetrieveUpdateAPIView.as_view(), name="song_retrieve_update"),
path("report-notes/", ReportNoteCreateAPIView.as_view(), name="report_note_create"), path("report-notes/", ReportNoteCreateAPIView.as_view(), name="report_note_create"),
path("upload/", SongUploadAPIView.as_view(), name="song_upload"), path("upload/", SongUploadAPIView.as_view(), name="song_upload"),
] ]

View File

@ -1,6 +1,7 @@
from django.conf import settings
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.generics import ListAPIView, RetrieveAPIView, CreateAPIView from rest_framework.generics import ListAPIView, RetrieveUpdateAPIView, CreateAPIView
from rest_framework import filters from rest_framework import filters, status
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.response import Response from rest_framework.response import Response
@ -27,11 +28,35 @@ class SongsListAPIView(ListAPIView):
] ]
class SongRetrieveAPIView(RetrieveAPIView): class SongRetrieveUpdateAPIView(RetrieveUpdateAPIView):
serializer_class = SongSerializer serializer_class = SongSerializer
queryset = Song.objects.all() queryset = Song.objects.all()
permission_classes = [IsAuthenticatedOrTokenHasScopeForMethod] 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): class ReportNoteCreateAPIView(CreateAPIView):