From 24cb1d95191d3bc83278ff30da37c2b4b2a35534 Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Wed, 9 May 2018 22:18:33 +0200
Subject: [PATCH] See #75: user can now manage the Subsonic API token from
 their settings page

---
 api/funkwhale_api/users/views.py              |  26 +++-
 api/tests/subsonic/test_views.py              |   7 +
 api/tests/users/test_views.py                 |  71 +++++++++
 front/src/components/auth/Settings.vue        |  27 +++-
 .../src/components/auth/SubsonicTokenForm.vue | 137 ++++++++++++++++++
 5 files changed, 264 insertions(+), 4 deletions(-)
 create mode 100644 front/src/components/auth/SubsonicTokenForm.vue

diff --git a/api/funkwhale_api/users/views.py b/api/funkwhale_api/users/views.py
index 7c58363a..0cc31788 100644
--- a/api/funkwhale_api/users/views.py
+++ b/api/funkwhale_api/users/views.py
@@ -1,11 +1,13 @@
 from rest_framework.response import Response
 from rest_framework import mixins
 from rest_framework import viewsets
-from rest_framework.decorators import list_route
+from rest_framework.decorators import detail_route, list_route
 
 from rest_auth.registration.views import RegisterView as BaseRegisterView
 from allauth.account.adapter import get_adapter
 
+from funkwhale_api.common import preferences
+
 from . import models
 from . import serializers
 
@@ -37,6 +39,28 @@ class UserViewSet(
         serializer = serializers.UserReadSerializer(request.user)
         return Response(serializer.data)
 
+    @detail_route(
+        methods=['get', 'post', 'delete'], url_path='subsonic-token')
+    def subsonic_token(self, request, *args, **kwargs):
+        if not self.request.user.username == kwargs.get('username'):
+            return Response(status=403)
+        if not preferences.get('subsonic__enabled'):
+            return Response(status=405)
+        if request.method.lower() == 'get':
+            return Response({
+                'subsonic_api_token': self.request.user.subsonic_api_token
+            })
+        if request.method.lower() == 'delete':
+            self.request.user.subsonic_api_token = None
+            self.request.user.save(update_fields=['subsonic_api_token'])
+            return Response(status=204)
+        self.request.user.update_subsonic_api_token()
+        self.request.user.save(update_fields=['subsonic_api_token'])
+        data = {
+            'subsonic_api_token': self.request.user.subsonic_api_token
+        }
+        return Response(data)
+
     def update(self, request, *args, **kwargs):
         if not self.request.user.username == kwargs.get('username'):
             return Response(status=403)
diff --git a/api/tests/subsonic/test_views.py b/api/tests/subsonic/test_views.py
index b69be0d4..bd445e07 100644
--- a/api/tests/subsonic/test_views.py
+++ b/api/tests/subsonic/test_views.py
@@ -45,6 +45,13 @@ def test_exception_wrong_credentials(f, db, api_client):
     assert response.data == expected
 
 
+def test_disabled_subsonic(preferences, api_client):
+    preferences['subsonic__enabled'] = False
+    url = reverse('api:subsonic-ping')
+    response = api_client.get(url)
+    assert response.status_code == 405
+
+
 @pytest.mark.parametrize('f', ['xml', 'json'])
 def test_get_license(f, db, logged_in_api_client, mocker):
     url = reverse('api:subsonic-get-license')
diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py
index 985a78c8..fffc762f 100644
--- a/api/tests/users/test_views.py
+++ b/api/tests/users/test_views.py
@@ -167,6 +167,77 @@ def test_user_can_patch_his_own_settings(logged_in_api_client):
     assert user.privacy_level == 'me'
 
 
+def test_user_can_request_new_subsonic_token(logged_in_api_client):
+    user = logged_in_api_client.user
+    user.subsonic_api_token = 'test'
+    user.save()
+
+    url = reverse(
+        'api:v1:users:users-subsonic-token',
+        kwargs={'username': user.username})
+
+    response = logged_in_api_client.post(url)
+
+    assert response.status_code == 200
+    user.refresh_from_db()
+    assert user.subsonic_api_token != 'test'
+    assert user.subsonic_api_token is not None
+    assert response.data == {
+        'subsonic_api_token': user.subsonic_api_token
+    }
+
+
+def test_user_can_get_new_subsonic_token(logged_in_api_client):
+    user = logged_in_api_client.user
+    user.subsonic_api_token = 'test'
+    user.save()
+
+    url = reverse(
+        'api:v1:users:users-subsonic-token',
+        kwargs={'username': user.username})
+
+    response = logged_in_api_client.get(url)
+
+    assert response.status_code == 200
+    assert response.data == {
+        'subsonic_api_token': 'test'
+    }
+def test_user_can_request_new_subsonic_token(logged_in_api_client):
+    user = logged_in_api_client.user
+    user.subsonic_api_token = 'test'
+    user.save()
+
+    url = reverse(
+        'api:v1:users:users-subsonic-token',
+        kwargs={'username': user.username})
+
+    response = logged_in_api_client.post(url)
+
+    assert response.status_code == 200
+    user.refresh_from_db()
+    assert user.subsonic_api_token != 'test'
+    assert user.subsonic_api_token is not None
+    assert response.data == {
+        'subsonic_api_token': user.subsonic_api_token
+    }
+
+
+def test_user_can_delete_subsonic_token(logged_in_api_client):
+    user = logged_in_api_client.user
+    user.subsonic_api_token = 'test'
+    user.save()
+
+    url = reverse(
+        'api:v1:users:users-subsonic-token',
+        kwargs={'username': user.username})
+
+    response = logged_in_api_client.delete(url)
+
+    assert response.status_code == 204
+    user.refresh_from_db()
+    assert user.subsonic_api_token is None
+
+
 @pytest.mark.parametrize('method', ['put', 'patch'])
 def test_user_cannot_patch_another_user(
         method, logged_in_api_client, factories):
diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue
index 8eeae85a..5468358a 100644
--- a/front/src/components/auth/Settings.vue
+++ b/front/src/components/auth/Settings.vue
@@ -26,6 +26,10 @@
       <div class="ui hidden divider"></div>
       <div class="ui small text container">
         <h2 class="ui header"><i18next path="Change my password"/></h2>
+        <div class="ui message">
+          {{ $t('Changing your password will also change your Subsonic API password if you have requested one.') }}
+          {{ $t('You will have to update your password on your clients that use this password.') }}
+        </div>
         <form class="ui form" @submit.prevent="submitPassword()">
           <div v-if="passwordError" class="ui negative message">
             <div class="header"><i18next path="Cannot change your password"/></div>
@@ -41,10 +45,25 @@
           <div class="field">
             <label><i18next path="New password"/></label>
             <password-input required v-model="new_password" />
-
           </div>
-          <button :class="['ui', {'loading': isLoading}, 'button']" type="submit"><i18next path="Change password"/></button>
+          <dangerous-button
+            color="yellow"
+            :class="['ui', {'loading': isLoading}, 'button']"
+            :action="submitPassword">
+            {{ $t('Change password') }}
+            <p slot="modal-header">{{ $t('Change your password?') }}</p>
+            <div slot="modal-content">
+              <p>{{ $t("Changing your password will have the following consequences") }}</p>
+              <ul>
+                <li>{{ $t('You will be logged out from this session and have to log out with the new one') }}</li>
+                <li>{{ $t('Your Subsonic password will be changed to a new, random one, logging you out from devices that used the old Subsonic password') }}</li>
+              </ul>
+            </div>
+            <p slot="modal-confirm">{{ $t('Disable access') }}</p>
+          </dangerous-button>
         </form>
+        <div class="ui hidden divider" />
+        <subsonic-token-form />
       </div>
     </div>
   </div>
@@ -55,10 +74,12 @@ import $ from 'jquery'
 import axios from 'axios'
 import logger from '@/logging'
 import PasswordInput from '@/components/forms/PasswordInput'
+import SubsonicTokenForm from '@/components/auth/SubsonicTokenForm'
 
 export default {
   components: {
-    PasswordInput
+    PasswordInput,
+    SubsonicTokenForm
   },
   data () {
     let d = {
diff --git a/front/src/components/auth/SubsonicTokenForm.vue b/front/src/components/auth/SubsonicTokenForm.vue
new file mode 100644
index 00000000..dd0bd5ca
--- /dev/null
+++ b/front/src/components/auth/SubsonicTokenForm.vue
@@ -0,0 +1,137 @@
+<template>
+  <form class="ui form" @submit.prevent="requestNewToken()">
+    <h2>{{ $t('Subsonic API password') }}</h2>
+    <p class="ui message" v-if="!subsonicEnabled">
+      {{ $t('The Subsonic API is not available on this Funkwhale instance.') }}
+    </p>
+    <p>
+      {{ $t('Funkwhale is compatible with other music players that support the Subsonic API.') }}
+      {{ $t('You can use those to enjoy your playlist and music in offline mode, on your smartphone or tablet, for instance.') }}
+    </p>
+    <p>
+      {{ $t('However, accessing Funkwhale from those clients require a separate password you can set below.') }}
+    </p>
+    <p><a href="https://docs.funkwhale.audio/users/apps.html#subsonic" target="_blank">
+      {{ $t('Discover how to use Funkwhale from other apps') }}
+    </a></p>
+    <div v-if="success" class="ui positive message">
+      <div class="header">{{ successMessage }}</div>
+    </div>
+    <div v-if="subsonicEnabled && errors.length > 0" class="ui negative message">
+      <div class="header">{{ $t('Error') }}</div>
+      <ul class="list">
+        <li v-for="error in errors">{{ error }}</li>
+      </ul>
+    </div>
+    <template v-if="subsonicEnabled">
+      <div v-if="token" class="field">
+        <password-input v-model="token" />
+      </div>
+      <dangerous-button
+        v-if="token"
+        color="grey"
+        :class="['ui', {'loading': isLoading}, 'button']"
+        :action="requestNewToken">
+        {{ $t('Request a new password') }}
+        <p slot="modal-header">{{ $t('Request a new Subsonic API password?') }}</p>
+        <p slot="modal-content">{{ $t('This will log you out from existing devices that use the current password.') }}</p>
+        <p slot="modal-confirm">{{ $t('Request a new password') }}</p>
+      </dangerous-button>
+      <button
+        v-else
+        color="grey"
+        :class="['ui', {'loading': isLoading}, 'button']"
+        @click="requestNewToken">{{ $t('Request a password') }}</button>
+        <dangerous-button
+          v-if="token"
+          color="yellow"
+          :class="['ui', {'loading': isLoading}, 'button']"
+          :action="disable">
+          {{ $t('Disable Subsonic access') }}
+          <p slot="modal-header">{{ $t('Disable Subsonic API access?') }}</p>
+          <p slot="modal-content">{{ $t('This will completely disable access to the Subsonic API using from account.') }}</p>
+          <p slot="modal-confirm">{{ $t('Disable access') }}</p>
+        </dangerous-button>
+    </template>
+  </form>
+</template>
+
+<script>
+import axios from 'axios'
+import PasswordInput from '@/components/forms/PasswordInput'
+
+export default {
+  components: {
+    PasswordInput
+  },
+  data () {
+    return {
+      token: null,
+      errors: [],
+      success: false,
+      isLoading: false,
+      successMessage: ''
+    }
+  },
+  created () {
+    this.fetchToken()
+  },
+  methods: {
+    fetchToken () {
+      this.success = false
+      this.errors = []
+      this.isLoading = true
+      let self = this
+      let url = `users/users/${this.$store.state.auth.username}/subsonic-token/`
+      return axios.get(url).then(response => {
+        self.token = response.data['subsonic_api_token']
+        self.isLoading = false
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    },
+    requestNewToken () {
+      this.successMessage = this.$t('Password updated')
+      this.success = false
+      this.errors = []
+      this.isLoading = true
+      let self = this
+      let url = `users/users/${this.$store.state.auth.username}/subsonic-token/`
+      return axios.post(url, {}).then(response => {
+        self.token = response.data['subsonic_api_token']
+        self.isLoading = false
+        self.success = true
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    },
+    disable () {
+      this.successMessage = this.$t('Access disabled')
+      this.success = false
+      this.errors = []
+      this.isLoading = true
+      let self = this
+      let url = `users/users/${this.$store.state.auth.username}/subsonic-token/`
+      return axios.delete(url).then(response => {
+        self.isLoading = false
+        self.token = null
+        self.success = true
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    }
+  },
+  computed: {
+    subsonicEnabled () {
+      return this.$store.state.instance.settings.subsonic.enabled.value
+    }
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>
-- 
GitLab