Merge branch 'release/0.11'

This commit is contained in:
Eliot Berriot 2018-05-06 16:24:20 +02:00
commit 104a247d97
No known key found for this signature in database
GPG Key ID: DD6965E2476E5C27
111 changed files with 2444 additions and 717 deletions

View File

@ -1,4 +1,3 @@
API_AUTHENTICATION_REQUIRED=True
RAVEN_ENABLED=false
RAVEN_DSN=https://44332e9fdd3d42879c7d35bf8562c6a4:0062dc16a22b41679cd5765e5342f716@sentry.eliotberriot.com/5
DJANGO_ALLOWED_HOSTS=.funkwhale.test,localhost,nginx,0.0.0.0,127.0.0.1

1
.gitignore vendored
View File

@ -89,3 +89,4 @@ data/
.env
po/*.po
docs/swagger

View File

@ -92,12 +92,14 @@ build_front:
pages:
stage: test
image: python:3.6-alpine
image: python:3.6
variables:
BUILD_PATH: "../public"
before_script:
- cd docs
script:
- pip install sphinx
- python -m sphinx . ../public
- ./build_docs.sh
artifacts:
paths:
- public

112
CHANGELOG
View File

@ -1,8 +1,120 @@
Changelog
=========
You can subscribe to release announcements by:
- Following `funkwhale@mastodon.eliotberriot.com <https://mastodon.eliotberriot.com/@funkwhale>`_ on Mastodon
- Subscribing to the following Atom feed: https://code.eliotberriot.com/funkwhale/funkwhale/commits/develop?format=atom&search=tag
This changelog is viewable on the web at https://docs.funkwhale.audio/changelog.html.
.. towncrier
0.11 (unreleased)
-----------------
Upgrade instructions are available at https://docs.funkwhale.audio/upgrading.html
Special thanks for this release go to @renon:matrix.org (@Hazmo on Gitlab)
for bringing Apache2 support to Funkwhale and contributing on other issues.
Thank you!
Features:
- Funkwhale now works behind an Apache2 reverse proxy (!165)
check out the brand new documentation at https://docs.funkwhale.audio/installation/index.html#apache2
if you want to try it!
- Users can now request password reset by email, assuming a SMTP server was
correctly configured (#187)
Enhancements:
- Added a fix_track_files command to run checks and fixes against library
(#183)
- Avoid fetching Actor object on every request authentication
- Can now relaunch errored jobs and batches (#176)
- List pending requests by default, added a status filter for requests (#109)
- More structured menus in sidebar, added labels with notifications
- Sample virtual-host file for Apache2 reverse-proxy (!165)
- Store high-level settings (such as federation or auth-related ones) in
database (#186)
Bugfixes:
- Ensure in place imported files get a proper mimetype (#183)
- Federation cache suppression is now simpler and also deletes orphaned files
(#189)
- Fixed small UI glitches/bugs in federation tabs (#184)
- X-sendfile not working with in place import (#182)
Documentation:
- Added a documentation area for third-party projects (#180)
- Added documentation for optimizing Funkwhale and reduce its memory footprint.
- Document that the database should use an utf-8 encoding (#185)
- Foundations for API documentation with Swagger (#178)
Database storage for high-level settings
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Due to the work done in #186, the following environment variables have been
deprecated:
- FEDERATION_ENABLED
- FEDERATION_COLLECTION_PAGE_SIZE
- FEDERATION_MUSIC_NEEDS_APPROVAL
- FEDERATION_ACTOR_FETCH_DELAY
- PLAYLISTS_MAX_TRACKS
- API_AUTHENTICATION_REQUIRED
Configuration for this settings has been moved to database, as it will provide
a better user-experience, by allowing you to edit these values on-the-fly,
without restarting Funkwhale processes.
You can leave those environment variables in your .env file for now, as the
values will be used to populate the database entries. We'll make a proper
announcement when the variables won't be used anymore.
Please browse https://docs.funkwhale.audio/configuration.html#instance-settings
for more information about instance configuration using the web interface.
System emails
^^^^^^^^^^^^^
Starting from this release, Funkwhale will send two types
of emails:
- Email confirmation emails, to ensure a user's email is valid
- Password reset emails, enabling user to reset their password without an admin's intervention
Email sending is disabled by default, as it requires additional configuration.
In this mode, emails are simply outputed on stdout.
If you want to actually send those emails to your users, you should edit your
.env file and tweak the EMAIL_CONFIG variable. See :ref:`setting-EMAIL_CONFIG`
for more details.
.. note::
As a result of these changes, the DJANGO_EMAIL_BACKEND variable,
which was not documented, has no effect anymore. You can safely remove it from
your .env file if it is set.
Proxy headers for non-docker deployments
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
For non-docker deployments, add ``--proxy-headers`` at the end of the ``daphne``
command in :file:`/etc/systemd/system/funkwhale-server.service`.
This will ensure the application receive the correct IP address from the client
and not the proxy's one.
0.10 (2018-04-23)
-----------------

442
CONTRIBUTING Normal file
View File

@ -0,0 +1,442 @@
Contibute to Funkwhale development
==================================
First of all, thank you for your interest in the project! We really
appreciate the fact that you're about to take some time to read this
and hack on the project.
This document will guide you through common operations such as:
- Setup your development environment
- Working on your first issue
- Writing unit tests to validate your work
- Submit your work
Setup your development environment
----------------------------------
If you want to fix a bug or implement a feature, you'll need
to run a local, development copy of funkwhale.
We provide a docker based development environment, which should
be both easy to setup and work similarly regardless of your
development machine setup.
Instructions for bare-metal setup will come in the future (Merge requests
are welcome).
Installing docker and docker-compose
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This is already cover in the relevant documentations:
- https://docs.docker.com/install/
- https://docs.docker.com/compose/install/
Cloning the project
^^^^^^^^^^^^^^^^^^^
Visit https://code.eliotberriot.com/funkwhale/funkwhale and clone the repository using SSH or HTTPS. Exemple using SSH::
git clone ssh://git@code.eliotberriot.com:2222/funkwhale/funkwhale.git
cd funkwhale
A note about branches
^^^^^^^^^^^^^^^^^^^^^
Next release development occurs on the "develop" branch, and releases are made on the "master" branch. Therefor, when submitting Merge Requests, ensure you are merging on the develop branch.
Working with docker
^^^^^^^^^^^^^^^^^^^
In developpement, we use the docker-compose file named ``dev.yml``, and this is why all our docker-compose commands will look like this::
docker-compose -f dev.yml logs
If you do not want to add the ``-f dev.yml`` snippet everytime, you can run this command before starting your work::
export COMPOSE_FILE=dev.yml
Building the containers
^^^^^^^^^^^^^^^^^^^^^^^
On your initial clone, or if there have been some changes in the
app dependencies, you will have to rebuild your containers. This is done
via the following command::
docker-compose -f dev.yml build
Creating your env file
^^^^^^^^^^^^^^^^^^^^^^
We provide a working .env.dev configuration file that is suitable for
development. However, to enable customization on your machine, you should
also create a .env file that will hold your personal environment
variables (those will not be commited to the project).
Create it like this::
touch .env
Database management
^^^^^^^^^^^^^^^^^^^
To setup funkwhale's database schema, run this::
docker-compose -f dev.yml run --rm api python manage.py migrate
This will create all the tables needed for the API to run proprely.
You will also need to run this whenever changes are made on the database
schema.
It is safe to run this command multiple times, so you can run it whenever
you fetch develop.
Development data
^^^^^^^^^^^^^^^^
You'll need at least an admin user and some artists/tracks/albums to work
locally.
Create an admin user with the following command::
docker-compose -f dev.yml run --rm api python manage.py createsuperuser
Injecting fake data is done by running the fllowing script::
artists=25
command="from funkwhale_api.music import fake_data; fake_data.create_data($artists)"
echo $command | docker-compose -f dev.yml run --rm api python manage.py shell -i python
The previous command will create 25 artists with random albums, tracks
and metadata.
Launch all services
^^^^^^^^^^^^^^^^^^^
Then you can run everything with::
docker-compose -f dev.yml up
This will launch all services, and output the logs in your current terminal window.
If you prefer to launch them in the background instead, use the ``-d`` flag, and access the logs when you need it via ``docker-compose -f dev.yml logs --tail=50 --follow``.
Once everything is up, you can access the various funkwhale's components:
- The Vue webapp, on http://localhost:8080
- The API, on http://localhost:8080/api/v1/
- The django admin, on http://localhost:8080/api/admin/
Stopping everything
^^^^^^^^^^^^^^^^^^^
Once you're down with your work, you can stop running containers, if any, with::
docker-compose -f dev.yml stop
Removing everything
^^^^^^^^^^^^^^^^^^^
If you want to wipe your development environment completely (e.g. if you want to start over from scratch), just run::
docker-compose -f dev.yml down -v
This will wipe your containers and data, so please be careful before running it.
You can keep your data by removing the ``-v`` flag.
Working with federation locally
-------------------------------
This is not needed unless you need to work on federation-related features.
To achieve that, you'll need:
1. to update your dns resolver to resolve all your .dev hostnames locally
2. a reverse proxy (such as traefik) to catch those .dev requests and
and with https certificate
3. two instances (or more) running locally, following the regular dev setup
Resolve .dev names locally
^^^^^^^^^^^^^^^^^^^^^^^^^^
If you use dnsmasq, this is as simple as doing::
echo "address=/test/172.17.0.1" | sudo tee /etc/dnsmasq.d/test.conf
sudo systemctl restart dnsmasq
If you use NetworkManager with dnsmasq integration, use this instead::
echo "address=/test/172.17.0.1" | sudo tee /etc/NetworkManager/dnsmasq.d/test.conf
sudo systemctl restart NetworkManager
Add wildcard certificate to the trusted certificates
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Simply copy bundled certificates::
sudo cp docker/ssl/test.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
This certificate is a wildcard for ``*.funkwhale.test``
Run a reverse proxy for your instances
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Create docker network
^^^^^^^^^^^^^^^^^^^^
Create the federation network::
docker network create federation
Launch everything
^^^^^^^^^^^^^^^^^
Launch the traefik proxy::
docker-compose -f docker/traefik.yml up -d
Then, in separate terminals, you can setup as many different instances as you
need::
export COMPOSE_PROJECT_NAME=node2
docker-compose -f dev.yml run --rm api python manage.py migrate
docker-compose -f dev.yml run --rm api python manage.py createsuperuser
docker-compose -f dev.yml up nginx api front nginx api celeryworker
Note that by default, if you don't export the COMPOSE_PROJECT_NAME,
we will default to node1 as the name of your instance.
Assuming your project name is ``node1``, your server will be reachable
at ``https://node1.funkwhale.test/``. Not that you'll have to trust
the SSL Certificate as it's self signed.
When working on federation with traefik, ensure you have this in your ``env``::
# This will ensure we don't bind any port on the host, and thus enable
# multiple instances of funkwhale to be spawned concurrently.
WEBPACK_DEVSERVER_PORT_BINDING=
# This disable certificate verification
EXTERNAL_REQUESTS_VERIFY_SSL=false
# this ensure you don't have incorrect urls pointing to http resources
FUNKWHALE_PROTOCOL=https
Typical workflow for a contribution
-----------------------------------
0. Fork the project if you did not already or if you do not have access to the main repository
1. Checkout the development branch and pull most recent changes: ``git checkout develop && git pull``
2. If working on an issue, assign yourself to the issue. Otherwise, consider open an issue before starting to work on something, especially for new features.
3. Create a dedicated branch for your work ``42-awesome-fix``. It is good practice to prefix your branch name with the ID of the issue you are solving.
4. Work on your stuff
5. Commit small, atomic changes to make it easier to review your contribution
6. Add a changelog fragment to summarize your changes: ``echo "Implemented awesome stuff (#42)" > changes/changelog.d/42.feature"``
7. Push your branch
8. Create your merge request
9. Take a step back and enjoy, we're really grateful you did all of this and took the time to contribute!
Internationalization
--------------------
When working on the front-end, any end-user string should be translated
using either ``<i18next path="yourstring">`` or the ``$t('yourstring')``
function.
Extraction is done by calling ``yarn run i18n-extract``, which
will pull all the strings from source files and put them in a PO file.
Contributing to the API
-----------------------
Project structure
^^^^^^^^^^^^^^^^^
.. code-block:: shell
tree api -L 2 -d
api
├── config # configuration directory (settings, urls, wsgi server)
│ └── settings # Django settings files
├── funkwhale_api # project directory, all funkwhale logic is here
├── requirements # python requirements files
└── tests # test files, matches the structure of the funkwhale_api directory
.. note::
Unless trivial, API contributions must include unittests to ensure
your fix or feature is working as expected and won't break in the future
Running tests
^^^^^^^^^^^^^
To run the pytest test suite, use the following command::
docker-compose -f dev.yml run --rm api pytest
This is regular pytest, so you can use any arguments/options that pytest usually accept::
# get some help
docker-compose -f dev.yml run --rm api pytest -h
# Stop on first failure
docker-compose -f dev.yml run --rm api pytest -x
# Run a specific test file
docker-compose -f dev.yml run --rm api pytest tests/test_acoustid.py
Writing tests
^^^^^^^^^^^^^
Although teaching you how to write unit tests is outside of the scope of this
document, you'll find below a collection of tips, snippets and resources
you can use if you want to learn on that subject.
Useful links:
- `A quick introduction to unit test writing with pytest <https://semaphoreci.com/community/tutorials/testing-python-applications-with-pytest>`_
- `A complete guide to Test-Driven Development (although not using Pytest) <https://www.obeythetestinggoat.com/>`_
- `pytest <https://docs.pytest.org/en/latest/>`_: documentation of our testing engine and runner
- `pytest-mock <https://pypi.org/project/pytest-mock/>`_: project page of our mocking engine
- `factory-boy <http://factoryboy.readthedocs.io/>`_: documentation of factory-boy, which we use to easily generate fake objects and data
Recommendations:
- Test files must target a module and mimic ``funkwhale_api`` directory structure: if you're writing tests for ``funkwhale_api/myapp/views.py``, you should put thoses tests in ``tests/myapp/test_views.py``
- Tests should be small and test one thing. If you need to test multiple things, write multiple tests.
We provide a lot of utils and fixtures to make the process of writing tests as
painless as possible. You'll find some usage examples below.
Use factories to create arbitrary objects:
.. code-block:: python
# funkwhale_api/myapp/users.py
def downgrade_user(user):
"""
A simple function that remove superuser status from users
and return True if user was actually downgraded
"""
downgraded = user.is_superuser
user.is_superuser = False
user.save()
return downgraded
# tests/myapp/test_users.py
from funkwhale_api.myapp import users
def test_downgrade_superuser(factories):
user = factories['users.User'](is_superuser=True)
downgraded = users.downgrade_user(user)
assert downgraded is True
assert user.is_superuser is False
def test_downgrade_normal_user_does_nothing(factories):
user = factories['users.User'](is_superuser=False)
downgraded = something.downgrade_user(user)
assert downgraded is False
assert user.is_superuser is False
.. note::
We offer factories for almost if not all models. Factories are located
in a ``factories.py`` file inside each app.
Mocking: mocking is the process of faking some logic in our code. This is
useful when testing components that depend on each other:
.. code-block:: python
# funkwhale_api/myapp/notifications.py
def notify(email, message):
"""
A function that sends an email to the given recipient
with the given message
"""
# our email sending logic here
# ...
# funkwhale_api/myapp/users.py
from . import notifications
def downgrade_user(user):
"""
A simple function that remove superuser status from users
and return True if user was actually downgraded
"""
downgraded = user.is_superuser
user.is_superuser = False
user.save()
if downgraded:
notifications.notify(user.email, 'You have been downgraded!')
return downgraded
# tests/myapp/test_users.py
def test_downgrade_superuser_sends_email(factories, mocker):
"""
Your downgrade logic is already tested, however, we want to ensure
an email is sent when user is downgraded, but we don't have any email
server available in our testing environment. Thus, we need to mock
the email sending process.
"""
mocked_notify = mocker.patch('funkwhale_api.myapp.notifications.notify')
user = factories['users.User'](is_superuser=True)
users.downgrade_user(user)
# here, we ensure our notify function was called with proper arguments
mocked_notify.assert_called_once_with(user.email, 'You have been downgraded')
def test_downgrade_not_superuser_skips_email(factories, mocker):
mocked_notify = mocker.patch('funkwhale_api.myapp.notifications.notify')
user = factories['users.User'](is_superuser=True)
users.downgrade_user(user)
# here, we ensure no email was sent
mocked_notify.assert_not_called()
Views: you can find some readable views tests in :file:`tests/users/test_views.py`
.. note::
A complete list of available-fixtures is available by running
``docker-compose -f dev.yml run --rm api pytest --fixtures``
Contributing to the front-end
-----------------------------
Running tests
^^^^^^^^^^^^^
To run the front-end test suite, use the following command::
docker-compose -f dev.yml run --rm front yarn run unit
We also support a "watch and test" mode were we continually relaunch
tests when changes are recorded on the file system::
docker-compose -f dev.yml run --rm front yarn run unit-watch
The latter is especially useful when you are debugging failing tests.
.. note::
The front-end test suite coverage is still pretty low

View File

@ -1,6 +1,10 @@
Funkwhale
=============
.. image:: ./front/src/assets/logo/logo-full-500.png
:alt: Funkwhale logo
:target: https://funkwhale.audio
A self-hosted tribute to Grooveshark.com.
LICENSE: BSD
@ -8,289 +12,18 @@ LICENSE: BSD
Getting help
------------
We offer various Matrix.org rooms to discuss about funkwhale:
We offer various Matrix.org rooms to discuss about Funkwhale:
- `#funkwhale:matrix.org <https://riot.im/app/#/room/#funkwhale:matrix.org>`_ for general questions about funkwhale
- `#funkwhale-dev:matrix.org <https://riot.im/app/#/room/#funkwhale-dev:matrix.org>`_ for development-focused discussion
Please join those rooms if you have any questions!
Running the development version
-------------------------------
You can also contact `@funkwhale@mastodon.eliotberriot.com <https://mastodon.eliotberriot.com/@funkwhale>`_ on the fediverse.
If you want to fix a bug or implement a feature, you'll need
to run a local, development copy of funkwhale.
We provide a docker based development environment, which should
be both easy to setup and work similarly regardless of your
development machine setup.
Contribute
----------
Instructions for bare-metal setup will come in the future (Merge requests
are welcome).
Installing docker and docker-compose
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This is already cover in the relevant documentations:
- https://docs.docker.com/install/
- https://docs.docker.com/compose/install/
Cloning the project
^^^^^^^^^^^^^^^^^^^
Visit https://code.eliotberriot.com/funkwhale/funkwhale and clone the repository using SSH or HTTPS. Exemple using SSH::
git clone ssh://git@code.eliotberriot.com:2222/funkwhale/funkwhale.git
cd funkwhale
A note about branches
^^^^^^^^^^^^^^^^^^^^^
Next release development occurs on the "develop" branch, and releases are made on the "master" branch. Therefor, when submitting Merge Requests, ensure you are merging on the develop branch.
Working with docker
^^^^^^^^^^^^^^^^^^^
In developpement, we use the docker-compose file named ``dev.yml``, and this is why all our docker-compose commands will look like this::
docker-compose -f dev.yml logs
If you do not want to add the ``-f dev.yml`` snippet everytime, you can run this command before starting your work::
export COMPOSE_FILE=dev.yml
Building the containers
^^^^^^^^^^^^^^^^^^^^^^^
On your initial clone, or if there have been some changes in the
app dependencies, you will have to rebuild your containers. This is done
via the following command::
docker-compose -f dev.yml build
Creating your env file
^^^^^^^^^^^^^^^^^^^^^^
We provide a working .env.dev configuration file that is suitable for
development. However, to enable customization on your machine, you should
also create a .env file that will hold your personal environment
variables (those will not be commited to the project).
Create it like this::
touch .env
Database management
^^^^^^^^^^^^^^^^^^^
To setup funkwhale's database schema, run this::
docker-compose -f dev.yml run --rm api python manage.py migrate
This will create all the tables needed for the API to run proprely.
You will also need to run this whenever changes are made on the database
schema.
It is safe to run this command multiple times, so you can run it whenever
you fetch develop.
Development data
^^^^^^^^^^^^^^^^
You'll need at least an admin user and some artists/tracks/albums to work
locally.
Create an admin user with the following command::
docker-compose -f dev.yml run --rm api python manage.py createsuperuser
Injecting fake data is done by running the fllowing script::
artists=25
command="from funkwhale_api.music import fake_data; fake_data.create_data($artists)"
echo $command | docker-compose -f dev.yml run --rm api python manage.py shell -i python
The previous command will create 25 artists with random albums, tracks
and metadata.
Launch all services
^^^^^^^^^^^^^^^^^^^
Then you can run everything with::
docker-compose -f dev.yml up
This will launch all services, and output the logs in your current terminal window.
If you prefer to launch them in the background instead, use the ``-d`` flag, and access the logs when you need it via ``docker-compose -f dev.yml logs --tail=50 --follow``.
Once everything is up, you can access the various funkwhale's components:
- The Vue webapp, on http://localhost:8080
- The API, on http://localhost:8080/api/v1/
- The django admin, on http://localhost:8080/api/admin/
Running API tests
^^^^^^^^^^^^^^^^^
To run the pytest test suite, use the following command::
docker-compose -f dev.yml run --rm api pytest
This is regular pytest, so you can use any arguments/options that pytest usually accept::
# get some help
docker-compose -f dev.yml run --rm api pytest -h
# Stop on first failure
docker-compose -f dev.yml run --rm api pytest -x
# Run a specific test file
docker-compose -f dev.yml run --rm api pytest tests/test_acoustid.py
Running front-end tests
^^^^^^^^^^^^^^^^^^^^^^^
To run the front-end test suite, use the following command::
docker-compose -f dev.yml run --rm front yarn run unit
We also support a "watch and test" mode were we continually relaunch
tests when changes are recorded on the file system::
docker-compose -f dev.yml run --rm front yarn run unit-watch
The latter is especially useful when you are debugging failing tests.
.. note::
The front-end test suite coverage is still pretty low
Stopping everything
^^^^^^^^^^^^^^^^^^^
Once you're down with your work, you can stop running containers, if any, with::
docker-compose -f dev.yml stop
Removing everything
^^^^^^^^^^^^^^^^^^^
If you want to wipe your development environment completely (e.g. if you want to start over from scratch), just run::
docker-compose -f dev.yml down -v
This will wipe your containers and data, so please be careful before running it.
You can keep your data by removing the ``-v`` flag.
Typical workflow for a merge request
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
0. Fork the project if you did not already or if you do not have access to the main repository
1. Checkout the development branch and pull most recent changes: ``git checkout develop && git pull``
2. Create a dedicated branch for your work ``42-awesome-fix``. It is good practice to prefix your branch name with the ID of the issue you are solving.
3. Work on your stuff
4. Commit small, atomic changes to make it easier to review your contribution
5. Add a changelog fragment to summarize your changes: ``echo "Implemented awesome stuff (#42)" > changes/changelog.d/42.feature"``
6. Push your branch
7. Create your merge request
8. Take a step back and enjoy, we're really grateful you did all of this and took the time to contribute!
Internationalization
--------------------
When working on the front-end, any end-user string should be translated
using either ``<i18next path="yourstring">`` or the ``$t('yourstring')``
function.
Extraction is done by calling ``yarn run i18n-extract``, which
will pull all the strings from source files and put them in a PO file.
Working with federation locally
-------------------------------
To achieve that, you'll need:
1. to update your dns resolver to resolve all your .dev hostnames locally
2. a reverse proxy (such as traefik) to catch those .dev requests and
and with https certificate
3. two instances (or more) running locally, following the regular dev setup
Resolve .dev names locally
^^^^^^^^^^^^^^^^^^^^^^^^^^
If you use dnsmasq, this is as simple as doing::
echo "address=/test/172.17.0.1" | sudo tee /etc/dnsmasq.d/test.conf
sudo systemctl restart dnsmasq
If you use NetworkManager with dnsmasq integration, use this instead::
echo "address=/test/172.17.0.1" | sudo tee /etc/NetworkManager/dnsmasq.d/test.conf
sudo systemctl restart NetworkManager
Add wildcard certificate to the trusted certificates
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Simply copy bundled certificates::
sudo cp docker/ssl/test.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
This certificate is a wildcard for ``*.funkwhale.test``
Run a reverse proxy for your instances
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Create docker network
^^^^^^^^^^^^^^^^^^^^
Create the federation network::
docker network create federation
Launch everything
^^^^^^^^^^^^^^^^^
Launch the traefik proxy::
docker-compose -f docker/traefik.yml up -d
Then, in separate terminals, you can setup as many different instances as you
need::
export COMPOSE_PROJECT_NAME=node2
docker-compose -f dev.yml run --rm api python manage.py migrate
docker-compose -f dev.yml run --rm api python manage.py createsuperuser
docker-compose -f dev.yml up nginx api front nginx api celeryworker
Note that by default, if you don't export the COMPOSE_PROJECT_NAME,
we will default to node1 as the name of your instance.
Assuming your project name is ``node1``, your server will be reachable
at ``https://node1.funkwhale.test/``. Not that you'll have to trust
the SSL Certificate as it's self signed.
When working on federation with traefik, ensure you have this in your ``env``::
# This will ensure we don't bind any port on the host, and thus enable
# multiple instances of funkwhale to be spawned concurrently.
WEBPACK_DEVSERVER_PORT_BINDING=
# This disable certificate verification
EXTERNAL_REQUESTS_VERIFY_SSL=false
# this ensure you don't have incorrect urls pointing to http resources
FUNKWHALE_PROTOCOL=https
Contribution guidelines as well as development installation instructions
are outlined in `CONTRIBUTING <CONTRIBUTING>`_

View File

@ -1,3 +1,3 @@
#!/bin/bash -eux
python /app/manage.py collectstatic --noinput
/usr/local/bin/daphne -b 0.0.0.0 -p 5000 config.asgi:application
/usr/local/bin/daphne -b 0.0.0.0 -p 5000 config.asgi:application --proxy-headers

View File

@ -48,14 +48,20 @@ else:
FUNKWHALE_URL = '{}://{}'.format(FUNKWHALE_PROTOCOL, FUNKWHALE_HOSTNAME)
# XXX: deprecated, see #186
FEDERATION_ENABLED = env.bool('FEDERATION_ENABLED', default=True)
FEDERATION_HOSTNAME = env('FEDERATION_HOSTNAME', default=FUNKWHALE_HOSTNAME)
# XXX: deprecated, see #186
FEDERATION_COLLECTION_PAGE_SIZE = env.int(
'FEDERATION_COLLECTION_PAGE_SIZE', default=50
)
# XXX: deprecated, see #186
FEDERATION_MUSIC_NEEDS_APPROVAL = env.bool(
'FEDERATION_MUSIC_NEEDS_APPROVAL', default=True
)
# XXX: deprecated, see #186
FEDERATION_ACTOR_FETCH_DELAY = env.int(
'FEDERATION_ACTOR_FETCH_DELAY', default=60 * 12)
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
# APP CONFIGURATION
@ -138,7 +144,6 @@ INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
MIDDLEWARE = (
# Make sure djangosecure.middleware.SecurityMiddleware is listed first
'django.contrib.sessions.middleware.SessionMiddleware',
'funkwhale_api.users.middleware.AnonymousSessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
@ -167,7 +172,22 @@ FIXTURE_DIRS = (
# EMAIL CONFIGURATION
# ------------------------------------------------------------------------------
EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND', default='django.core.mail.backends.smtp.EmailBackend')
# EMAIL
# ------------------------------------------------------------------------------
DEFAULT_FROM_EMAIL = env(
'DEFAULT_FROM_EMAIL',
default='Funkwhale <noreply@{}>'.format(FUNKWHALE_HOSTNAME))
EMAIL_SUBJECT_PREFIX = env(
"EMAIL_SUBJECT_PREFIX", default='[Funkwhale] ')
SERVER_EMAIL = env('SERVER_EMAIL', default=DEFAULT_FROM_EMAIL)
EMAIL_CONFIG = env.email_url(
'EMAIL_CONFIG', default='consolemail://')
vars().update(EMAIL_CONFIG)
# DATABASE CONFIGURATION
# ------------------------------------------------------------------------------
@ -287,7 +307,7 @@ AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend',
'allauth.account.auth_backends.AuthenticationBackend',
)
SESSION_COOKIE_HTTPONLY = False
# Some really nice defaults
ACCOUNT_AUTHENTICATION_METHOD = 'username_email'
ACCOUNT_EMAIL_REQUIRED = True
@ -362,7 +382,7 @@ CORS_ORIGIN_ALLOW_ALL = True
# 'funkwhale.localhost',
# )
CORS_ALLOW_CREDENTIALS = True
API_AUTHENTICATION_REQUIRED = env.bool("API_AUTHENTICATION_REQUIRED", True)
REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
@ -377,6 +397,7 @@ REST_FRAMEWORK = {
),
'DEFAULT_AUTHENTICATION_CLASSES': (
'funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS',
'funkwhale_api.common.authentication.BearerTokenHeaderAuth',
'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.BasicAuthentication',
@ -386,6 +407,11 @@ REST_FRAMEWORK = {
'django_filters.rest_framework.DjangoFilterBackend',
)
}
REST_AUTH_SERIALIZERS = {
'PASSWORD_RESET_SERIALIZER': 'funkwhale_api.users.serializers.PasswordResetSerializer' # noqa
}
REST_SESSION_LOGIN = False
REST_USE_JWT = True
ATOMIC_REQUESTS = False
USE_X_FORWARDED_HOST = True
@ -428,6 +454,7 @@ ADMIN_URL = env('DJANGO_ADMIN_URL', default='^api/admin/')
CSRF_USE_SESSIONS = True
# Playlist settings
# XXX: deprecated, see #186
PLAYLISTS_MAX_TRACKS = env.int('PLAYLISTS_MAX_TRACKS', default=250)
ACCOUNT_USERNAME_BLACKLIST = [
@ -447,6 +474,8 @@ EXTERNAL_REQUESTS_VERIFY_SSL = env.bool(
'EXTERNAL_REQUESTS_VERIFY_SSL',
default=True
)
# XXX: deprecated, see #186
API_AUTHENTICATION_REQUIRED = env.bool("API_AUTHENTICATION_REQUIRED", True)
MUSIC_DIRECTORY_PATH = env('MUSIC_DIRECTORY_PATH', default=None)
# on Docker setup, the music directory may not match the host path,

View File

@ -25,9 +25,6 @@ SECRET_KEY = env("DJANGO_SECRET_KEY", default='mc$&b=5j#6^bv7tld1gyjp2&+^-qrdy=0
# ------------------------------------------------------------------------------
EMAIL_HOST = 'localhost'
EMAIL_PORT = 1025
EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND',
default='django.core.mail.backends.console.EmailBackend')
# django-debug-toolbar
# ------------------------------------------------------------------------------

View File

@ -68,16 +68,6 @@ DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
# ------------------------
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.StaticFilesStorage'
# EMAIL
# ------------------------------------------------------------------------------
DEFAULT_FROM_EMAIL = env('DJANGO_DEFAULT_FROM_EMAIL',
default='funkwhale_api <noreply@funkwhale.io>')
EMAIL_SUBJECT_PREFIX = env("DJANGO_EMAIL_SUBJECT_PREFIX", default='[funkwhale_api] ')
SERVER_EMAIL = env('DJANGO_SERVER_EMAIL', default=DEFAULT_FROM_EMAIL)
# TEMPLATE CONFIGURATION
# ------------------------------------------------------------------------------
# See:

View File

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
__version__ = '0.10'
__version__ = '0.11'
__version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])

View File

@ -29,9 +29,6 @@ class TokenHeaderAuth(BaseJSONWebTokenAuthentication):
class TokenAuthMiddleware:
"""
Custom middleware (insecure) that takes user IDs from the query string.
"""
def __init__(self, inner):
# Store the ASGI application we were passed

View File

@ -1,3 +1,6 @@
from django.utils.encoding import smart_text
from django.utils.translation import ugettext as _
from rest_framework import exceptions
from rest_framework_jwt import authentication
from rest_framework_jwt.settings import api_settings
@ -18,3 +21,37 @@ class JSONWebTokenAuthenticationQS(
def authenticate_header(self, request):
return '{0} realm="{1}"'.format(
api_settings.JWT_AUTH_HEADER_PREFIX, self.www_authenticate_realm)
class BearerTokenHeaderAuth(
authentication.BaseJSONWebTokenAuthentication):
"""
For backward compatibility purpose, we used Authorization: JWT <token>
but Authorization: Bearer <token> is probably better.
"""
www_authenticate_realm = 'api'
def get_jwt_value(self, request):
auth = authentication.get_authorization_header(request).split()
auth_header_prefix = 'bearer'
if not auth:
if api_settings.JWT_AUTH_COOKIE:
return request.COOKIES.get(api_settings.JWT_AUTH_COOKIE)
return None
if smart_text(auth[0].lower()) != auth_header_prefix:
return None
if len(auth) == 1:
msg = _('Invalid Authorization header. No credentials provided.')
raise exceptions.AuthenticationFailed(msg)
elif len(auth) > 2:
msg = _('Invalid Authorization header. Credentials string '
'should not contain spaces.')
raise exceptions.AuthenticationFailed(msg)
return auth[1]
def authenticate_header(self, request):
return '{0} realm="{1}"'.format('Bearer', self.www_authenticate_realm)

View File

@ -0,0 +1,20 @@
from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry
from funkwhale_api.common import preferences
common = types.Section('common')
@global_preferences_registry.register
class APIAutenticationRequired(
preferences.DefaultFromSettingMixin, types.BooleanPreference):
section = common
name = 'api_authentication_required'
verbose_name = 'API Requires authentication'
setting = 'API_AUTHENTICATION_REQUIRED'
help_text = (
'If disabled, anonymous users will be able to query the API'
'and access music data (as well as other data exposed in the API '
'without specific permissions)'
)

View File

@ -5,11 +5,13 @@ from django.http import Http404
from rest_framework.permissions import BasePermission, DjangoModelPermissions
from funkwhale_api.common import preferences
class ConditionalAuthentication(BasePermission):
def has_permission(self, request, view):
if settings.API_AUTHENTICATION_REQUIRED:
if preferences.get('common__api_authentication_required'):
return request.user and request.user.is_authenticated
return True

View File

@ -0,0 +1,12 @@
from django.conf import settings
from dynamic_preferences.registries import global_preferences_registry
class DefaultFromSettingMixin(object):
def get_default(self):
return getattr(settings, self.setting)
def get(pref):
manager = global_preferences_registry.manager()
return manager[pref]

View File

@ -1,3 +1,4 @@
import datetime
import logging
import uuid
import xml
@ -11,6 +12,7 @@ from rest_framework.exceptions import PermissionDenied
from dynamic_preferences.registries import global_preferences_registry
from funkwhale_api.common import preferences
from funkwhale_api.common import session
from funkwhale_api.common import utils as funkwhale_utils
from funkwhale_api.music import models as music_models
@ -49,11 +51,20 @@ def get_actor_data(actor_url):
def get_actor(actor_url):
try:
actor = models.Actor.objects.get(url=actor_url)
except models.Actor.DoesNotExist:
actor = None
fetch_delta = datetime.timedelta(
minutes=preferences.get('federation__actor_fetch_delay'))
if actor and actor.last_fetch_date > timezone.now() - fetch_delta:
# cache is hot, we can return as is
return actor
data = get_actor_data(actor_url)
serializer = serializers.ActorSerializer(data=data)
serializer.is_valid(raise_exception=True)
return serializer.build()
return serializer.save(last_fetch_date=timezone.now())
class SystemActor(object):
@ -215,7 +226,7 @@ class LibraryActor(SystemActor):
@property
def manually_approves_followers(self):
return settings.FEDERATION_MUSIC_NEEDS_APPROVAL
return preferences.get('federation__music_needs_approval')
@transaction.atomic
def handle_create(self, ac, sender):

View File

@ -25,28 +25,19 @@ class SignatureAuthentication(authentication.BaseAuthentication):
raise exceptions.AuthenticationFailed(str(e))
try:
actor_data = actors.get_actor_data(key_id)
actor = actors.get_actor(key_id.split('#')[0])
except Exception as e:
raise exceptions.AuthenticationFailed(str(e))
try:
public_key = actor_data['publicKey']['publicKeyPem']
except KeyError:
if not actor.public_key:
raise exceptions.AuthenticationFailed('No public key found')
serializer = serializers.ActorSerializer(data=actor_data)
if not serializer.is_valid():
raise exceptions.AuthenticationFailed('Invalid actor payload: {}'.format(serializer.errors))
try:
signing.verify_django(request, public_key.encode('utf-8'))
signing.verify_django(request, actor.public_key.encode('utf-8'))
except cryptography.exceptions.InvalidSignature:
raise exceptions.AuthenticationFailed('Invalid signature')
try:
return models.Actor.objects.get(url=actor_data['id'])
except models.Actor.DoesNotExist:
return serializer.save()
return actor
def authenticate(self, request):
setattr(request, 'actor', None)

View File

@ -3,6 +3,7 @@ from django.forms import widgets
from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry
from funkwhale_api.common import preferences
federation = types.Section('federation')
@ -18,3 +19,53 @@ class MusicCacheDuration(types.IntPreference):
'locally? Federated files that were not listened in this interval '
'will be erased and refetched from the remote on the next listening.'
)
@global_preferences_registry.register
class Enabled(preferences.DefaultFromSettingMixin, types.BooleanPreference):
section = federation
name = 'enabled'
setting = 'FEDERATION_ENABLED'
verbose_name = 'Federation enabled'
help_text = (
'Use this setting to enable or disable federation logic and API'
' globally'
)
@global_preferences_registry.register
class CollectionPageSize(
preferences.DefaultFromSettingMixin, types.IntPreference):
section = federation
name = 'collection_page_size'
setting = 'FEDERATION_COLLECTION_PAGE_SIZE'
verbose_name = 'Federation collection page size'
help_text = (
'How much items to display in ActivityPub collections'
)
@global_preferences_registry.register
class ActorFetchDelay(
preferences.DefaultFromSettingMixin, types.IntPreference):
section = federation
name = 'actor_fetch_delay'
setting = 'FEDERATION_ACTOR_FETCH_DELAY'
verbose_name = 'Federation actor fetch delay'
help_text = (
'How much minutes to wait before refetching actors on '
'request authentication'
)
@global_preferences_registry.register
class MusicNeedsApproval(
preferences.DefaultFromSettingMixin, types.BooleanPreference):
section = federation
name = 'music_needs_approval'
setting = 'FEDERATION_MUSIC_NEEDS_APPROVAL'
verbose_name = 'Federation music needs approval'
help_text = (
'When true, other federation actors will require your approval'
' before being able to browse your library.'
)

View File

@ -2,13 +2,14 @@ from django.conf import settings
from rest_framework.permissions import BasePermission
from funkwhale_api.common import preferences
from . import actors
class LibraryFollower(BasePermission):
def has_permission(self, request, view):
if not settings.FEDERATION_MUSIC_NEEDS_APPROVAL:
if not preferences.get('federation__music_needs_approval'):
return True
actor = getattr(request, 'actor', None)

View File

@ -1,3 +1,4 @@
import logging
import urllib.parse
from django.urls import reverse
@ -21,6 +22,8 @@ AP_CONTEXT = [
{},
]
logger = logging.getLogger(__name__)
class ActorSerializer(serializers.Serializer):
id = serializers.URLField()
@ -100,9 +103,10 @@ class ActorSerializer(serializers.Serializer):
def save(self, **kwargs):
d = self.prepare_missing_fields()
d.update(kwargs)
return models.Actor.objects.create(
**d
)
return models.Actor.objects.update_or_create(
url=d['url'],
defaults=d,
)[0]
def validate_summary(self, value):
if value:
@ -620,6 +624,8 @@ class CollectionPageSerializer(serializers.Serializer):
for i in raw_items:
if i.is_valid():
valid_items.append(i)
else:
logger.debug('Invalid item %s: %s', i.data, i.errors)
return valid_items

View File

@ -1,8 +1,10 @@
import datetime
import json
import logging
import os
from django.conf import settings
from django.db.models import Q
from django.utils import timezone
from requests.exceptions import RequestException
@ -96,16 +98,38 @@ def clean_music_cache():
delay = preferences['federation__music_cache_duration']
if delay < 1:
return # cache clearing disabled
limit = timezone.now() - datetime.timedelta(minutes=delay)
candidates = models.LibraryTrack.objects.filter(
audio_file__isnull=False
).values_list('local_track_file__track', flat=True)
listenings = Listening.objects.filter(
creation_date__gte=timezone.now() - datetime.timedelta(minutes=delay),
track__pk__in=candidates).values_list('track', flat=True)
too_old = set(candidates) - set(listenings)
to_remove = models.LibraryTrack.objects.filter(
local_track_file__track__pk__in=too_old).only('audio_file')
for lt in to_remove:
Q(audio_file__isnull=False) & (
Q(local_track_file__accessed_date__lt=limit) |
Q(local_track_file__accessed_date=None)
)
).exclude(audio_file='').only('audio_file', 'id')
for lt in candidates:
lt.audio_file.delete()
# we also delete orphaned files, if any
storage = models.LibraryTrack._meta.get_field('audio_file').storage
files = get_files(storage, 'federation_cache')
existing = models.LibraryTrack.objects.filter(audio_file__in=files)
missing = set(files) - set(existing.values_list('audio_file', flat=True))
for m in missing:
storage.delete(m)
def get_files(storage, *parts):
"""
This is a recursive function that return all files available
in a given directory using django's storage.
"""
if not parts:
raise ValueError('Missing path')
dirs, files = storage.listdir(os.path.join(*parts))
for dir in dirs:
files += get_files(storage, *(list(parts) + [dir]))
return [
os.path.join(parts[-1], path)
for path in files
]

View File

@ -13,6 +13,7 @@ from rest_framework import viewsets
from rest_framework.decorators import list_route, detail_route
from rest_framework.serializers import ValidationError
from funkwhale_api.common import preferences
from funkwhale_api.common import utils as funkwhale_utils
from funkwhale_api.common.permissions import HasModelPermission
from funkwhale_api.music.models import TrackFile
@ -33,7 +34,7 @@ from . import webfinger
class FederationMixin(object):
def dispatch(self, request, *args, **kwargs):
if not settings.FEDERATION_ENABLED:
if not preferences.get('federation__enabled'):
return HttpResponse(status=405)
return super().dispatch(request, *args, **kwargs)
@ -136,7 +137,8 @@ class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet):
if page is None:
conf = {
'id': utils.full_url(reverse('federation:music:files-list')),
'page_size': settings.FEDERATION_COLLECTION_PAGE_SIZE,
'page_size': preferences.get(
'federation__collection_page_size'),
'items': qs,
'item_serializer': serializers.AudioSerializer,
'actor': library,
@ -150,7 +152,7 @@ class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet):
return response.Response(
{'page': ['Invalid page number']}, status=400)
p = paginator.Paginator(
qs, settings.FEDERATION_COLLECTION_PAGE_SIZE)
qs, preferences.get('federation__collection_page_size'))
try:
page = p.page(page_number)
conf = {

View File

@ -21,13 +21,6 @@ class Listening(models.Model):
class Meta:
ordering = ('-creation_date',)
def save(self, **kwargs):
if not self.user and not self.session_key:
raise ValidationError('Cannot have both session_key and user empty for listening')
super().save(**kwargs)
def get_activity_url(self):
return '{}/listenings/tracks/{}'.format(
self.user.get_activity_url(), self.pk)

View File

@ -36,13 +36,9 @@ class ListeningSerializer(serializers.ModelSerializer):
class Meta:
model = models.Listening
fields = ('id', 'user', 'session_key', 'track', 'creation_date')
fields = ('id', 'user', 'track', 'creation_date')
def create(self, validated_data):
if self.context.get('user'):
validated_data['user'] = self.context.get('user')
else:
validated_data['session_key'] = self.context['session_key']
validated_data['user'] = self.context['user']
return super().create(validated_data)

View File

@ -1,4 +1,5 @@
from rest_framework import generics, mixins, viewsets
from rest_framework import permissions
from rest_framework import status
from rest_framework.response import Response
from rest_framework.decorators import detail_route
@ -10,31 +11,26 @@ from funkwhale_api.music.serializers import TrackSerializerNested
from . import models
from . import serializers
class ListeningViewSet(mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet):
class ListeningViewSet(
mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet):
serializer_class = serializers.ListeningSerializer
queryset = models.Listening.objects.all()
permission_classes = [ConditionalAuthentication]
permission_classes = [permissions.IsAuthenticated]
def perform_create(self, serializer):
r = super().perform_create(serializer)
if self.request.user.is_authenticated:
record.send(serializer.instance)
record.send(serializer.instance)
return r
def get_queryset(self):
queryset = super().get_queryset()
if self.request.user.is_authenticated:
return queryset.filter(user=self.request.user)
else:
return queryset.filter(session_key=self.request.session.session_key)
return queryset.filter(user=self.request.user)
def get_serializer_context(self):
context = super().get_serializer_context()
if self.request.user.is_authenticated:
context['user'] = self.request.user
else:
context['session_key'] = self.request.session.session_key
context['user'] = self.request.user
return context

View File

@ -20,6 +20,9 @@ class ListenableMixin(filters.FilterSet):
class ArtistFilter(ListenableMixin):
q = fields.SearchFilter(search_fields=[
'name',
])
class Meta:
model = models.Artist

View File

@ -0,0 +1,45 @@
import cacheops
import os
from django.db import transaction
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from funkwhale_api.music import models, utils
class Command(BaseCommand):
help = 'Run common checks and fix against imported tracks'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
dest='dry_run',
default=False,
help='Do not execute anything'
)
def handle(self, *args, **options):
if options['dry_run']:
self.stdout.write('Dry-run on, will not commit anything')
self.fix_mimetypes(**options)
cacheops.invalidate_model(models.TrackFile)
@transaction.atomic
def fix_mimetypes(self, dry_run, **kwargs):
self.stdout.write('Fixing missing mimetypes...')
matching = models.TrackFile.objects.filter(
source__startswith='file://', mimetype=None)
self.stdout.write(
'[mimetypes] {} entries found with no mimetype'.format(
matching.count()))
for extension, mimetype in utils.EXTENSION_TO_MIMETYPE.items():
qs = matching.filter(source__endswith='.{}'.format(extension))
self.stdout.write(
'[mimetypes] setting {} {} files to {}'.format(
qs.count(), extension, mimetype
))
if not dry_run:
self.stdout.write('[mimetypes] commiting...')
qs.update(mimetype=mimetype)

View File

@ -0,0 +1,18 @@
# Generated by Django 2.0.3 on 2018-05-06 12:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0025_auto_20180419_2023'),
]
operations = [
migrations.AddField(
model_name='trackfile',
name='accessed_date',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@ -415,6 +415,7 @@ class TrackFile(models.Model):
source = models.URLField(null=True, blank=True, max_length=500)
creation_date = models.DateTimeField(default=timezone.now)
modification_date = models.DateTimeField(auto_now=True)
accessed_date = models.DateTimeField(null=True, blank=True)
duration = models.IntegerField(null=True, blank=True)
acoustid_track_id = models.UUIDField(null=True, blank=True)
mimetype = models.CharField(null=True, blank=True, max_length=200)
@ -463,26 +464,6 @@ class TrackFile(models.Model):
self.mimetype = utils.guess_mimetype(self.audio_file)
return super().save(**kwargs)
@property
def serve_from_source_path(self):
if not self.source or not self.source.startswith('file://'):
raise ValueError('Cannot serve this file from source')
serve_path = settings.MUSIC_DIRECTORY_SERVE_PATH
prefix = settings.MUSIC_DIRECTORY_PATH
if not serve_path or not prefix:
raise ValueError(
'You need to specify MUSIC_DIRECTORY_SERVE_PATH and '
'MUSIC_DIRECTORY_PATH to serve in-place imported files'
)
file_path = self.source.replace('file://', '', 1)
parts = os.path.split(file_path.replace(prefix, '', 1))
if parts[0] == '/':
parts = parts[1:]
return os.path.join(
serve_path,
*parts
)
IMPORT_STATUS_CHOICES = (
('pending', 'Pending'),

View File

@ -2,6 +2,7 @@ from django.conf import settings
from rest_framework.permissions import BasePermission
from funkwhale_api.common import preferences
from funkwhale_api.federation import actors
from funkwhale_api.federation import models
@ -12,6 +13,9 @@ class Listen(BasePermission):
if not settings.PROTECT_AUDIO_FILES:
return True
if not preferences.get('common__api_authentication_required'):
return True
user = getattr(request, 'user', None)
if user and user.is_authenticated:
return True

View File

@ -1,4 +1,5 @@
from django.db import transaction
from django.db.models import Q
from rest_framework import serializers
from taggit.models import Tag
@ -9,6 +10,7 @@ from funkwhale_api.federation.serializers import AP_CONTEXT
from funkwhale_api.users.serializers import UserBasicSerializer
from . import models
from . import tasks
class TagSerializer(serializers.ModelSerializer):
@ -204,3 +206,33 @@ class SubmitFederationTracksSerializer(serializers.Serializer):
source=lt.url,
)
return batch
class ImportJobRunSerializer(serializers.Serializer):
jobs = serializers.PrimaryKeyRelatedField(
many=True,
queryset=models.ImportJob.objects.filter(
status__in=['pending', 'errored']
)
)
batches = serializers.PrimaryKeyRelatedField(
many=True,
queryset=models.ImportBatch.objects.all()
)
def validate(self, validated_data):
jobs = validated_data['jobs']
batches_ids = [b.pk for b in validated_data['batches']]
query = Q(batch__pk__in=batches_ids)
query |= Q(pk__in=[j.id for j in jobs])
queryset = models.ImportJob.objects.filter(query).filter(
status__in=['pending', 'errored']).distinct()
validated_data['_jobs'] = queryset
return validated_data
def create(self, validated_data):
ids = validated_data['_jobs'].values_list('id', flat=True)
validated_data['_jobs'].update(status='pending')
for id in ids:
tasks.import_job_run.delay(import_job_id=id)
return {'jobs': list(ids)}

View File

@ -1,7 +1,8 @@
import os
from django.core.files.base import ContentFile
from dynamic_preferences.registries import global_preferences_registry
from funkwhale_api.common import preferences
from funkwhale_api.federation import activity
from funkwhale_api.federation import actors
from funkwhale_api.federation import models as federation_models
@ -13,6 +14,7 @@ from funkwhale_api.providers.audiofile.tasks import import_track_data_from_path
from django.conf import settings
from . import models
from . import lyrics as lyrics_utils
from . import utils as music_utils
@celery.app.task(name='acoustid.set_on_track_file')
@ -77,8 +79,7 @@ def _do_import(import_job, replace=False, use_acoustid=True):
acoustid_track_id = None
duration = None
track = None
manager = global_preferences_registry.manager()
use_acoustid = use_acoustid and manager['providers_acoustid__api_key']
use_acoustid = use_acoustid and preferences.get('providers_acoustid__api_key')
if not mbid and use_acoustid and from_file:
# we try to deduce mbid from acoustid
client = get_acoustid_client()
@ -129,6 +130,10 @@ def _do_import(import_job, replace=False, use_acoustid=True):
elif not import_job.audio_file and not import_job.source.startswith('file://'):
# not an implace import, and we have a source, so let's download it
track_file.download_file()
elif not import_job.audio_file and import_job.source.startswith('file://'):
# in place import, we set mimetype from extension
path, ext = os.path.splitext(import_job.source)
track_file.mimetype = music_utils.get_type_from_ext(ext)
track_file.save()
import_job.status = 'finished'
import_job.track_file = track_file
@ -178,7 +183,7 @@ def fetch_content(lyrics):
@celery.require_instance(
models.ImportBatch.objects.filter(status='finished'), 'import_batch')
def import_batch_notify_followers(import_batch):
if not settings.FEDERATION_ENABLED:
if not preferences.get('federation__enabled'):
return
if import_batch.source == 'federation':

View File

@ -63,8 +63,21 @@ def compute_status(jobs):
return 'finished'
AUDIO_EXTENSIONS_AND_MIMETYPE = [
('ogg', 'audio/ogg'),
('mp3', 'audio/mpeg'),
]
EXTENSION_TO_MIMETYPE = {ext: mt for ext, mt in AUDIO_EXTENSIONS_AND_MIMETYPE}
MIMETYPE_TO_EXTENSION = {mt: ext for ext, mt in AUDIO_EXTENSIONS_AND_MIMETYPE}
def get_ext_from_type(mimetype):
mapping = {
'audio/ogg': 'ogg',
'audio/mpeg': 'mp3',
}
return MIMETYPE_TO_EXTENSION.get(mimetype)
def get_type_from_ext(extension):
if extension.startswith('.'):
# we remove leading dot
extension = extension[1:]
return EXTENSION_TO_MIMETYPE.get(extension)

View File

@ -14,6 +14,7 @@ from django.db.models.functions import Length
from django.db.models import Count
from django.http import StreamingHttpResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.decorators import method_decorator
from rest_framework import viewsets, views, mixins
@ -145,6 +146,14 @@ class ImportJobViewSet(
data['count'] = sum([v for v in data.values()])
return Response(data)
@list_route(methods=['post'])
def run(self, request, *args, **kwargs):
serializer = serializers.ImportJobRunSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
payload = serializer.save()
return Response(payload)
def perform_create(self, serializer):
source = 'file://' + serializer.validated_data['audio_file'].name
serializer.save(source=source)
@ -206,6 +215,8 @@ class TrackViewSet(
def get_file_path(audio_file):
serve_path = settings.MUSIC_DIRECTORY_SERVE_PATH
prefix = settings.MUSIC_DIRECTORY_PATH
t = settings.REVERSE_PROXY_TYPE
if t == 'nginx':
# we have to use the internal locations
@ -213,14 +224,24 @@ def get_file_path(audio_file):
path = audio_file.url
except AttributeError:
# a path was given
path = '/music' + audio_file
if not serve_path or not prefix:
raise ValueError(
'You need to specify MUSIC_DIRECTORY_SERVE_PATH and '
'MUSIC_DIRECTORY_PATH to serve in-place imported files'
)
path = '/music' + audio_file.replace(prefix, '', 1)
return settings.PROTECT_FILES_PATH + path
if t == 'apache2':
try:
path = audio_file.path
except AttributeError:
# a path was given
path = audio_file
if not serve_path or not prefix:
raise ValueError(
'You need to specify MUSIC_DIRECTORY_SERVE_PATH and '
'MUSIC_DIRECTORY_PATH to serve in-place imported files'
)
path = audio_file.replace(prefix, serve_path, 1)
return path
@ -244,6 +265,10 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
except models.TrackFile.DoesNotExist:
return Response(status=404)
# we update the accessed_date
f.accessed_date = timezone.now()
f.save(update_fields=['accessed_date'])
mt = f.mimetype
audio_file = f.audio_file
try:
@ -267,7 +292,7 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
elif audio_file:
file_path = get_file_path(audio_file)
elif f.source and f.source.startswith('file://'):
file_path = get_file_path(f.serve_from_source_path)
file_path = get_file_path(f.source.replace('file://', '', 1))
response = Response()
filename = f.filename
mapping = {

View File

@ -0,0 +1,15 @@
from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry
from funkwhale_api.common import preferences
playlists = types.Section('playlists')
@global_preferences_registry.register
class MaxTracks(preferences.DefaultFromSettingMixin, types.IntegerPreference):
show_in_api = True
section = playlists
name = 'max_tracks'
verbose_name = 'Max tracks per playlist'
setting = 'PLAYLISTS_MAX_TRACKS'

View File

@ -6,6 +6,7 @@ from django.utils import timezone
from rest_framework import exceptions
from funkwhale_api.common import fields
from funkwhale_api.common import preferences
class Playlist(models.Model):
@ -81,10 +82,11 @@ class Playlist(models.Model):
existing = self.playlist_tracks.select_for_update()
now = timezone.now()
total = existing.filter(index__isnull=False).count()
if existing.count() + len(tracks) > settings.PLAYLISTS_MAX_TRACKS:
max_tracks = preferences.get('playlists__max_tracks')
if existing.count() + len(tracks) > max_tracks:
raise exceptions.ValidationError(
'Playlist would reach the maximum of {} tracks'.format(
settings.PLAYLISTS_MAX_TRACKS))
max_tracks))
self.save(update_fields=['modification_date'])
start = total
plts = [

View File

@ -3,6 +3,7 @@ from django.db import transaction
from rest_framework import serializers
from taggit.models import Tag
from funkwhale_api.common import preferences
from funkwhale_api.music.models import Track
from funkwhale_api.music.serializers import TrackSerializerNested
from funkwhale_api.users.serializers import UserBasicSerializer
@ -32,10 +33,11 @@ class PlaylistTrackWriteSerializer(serializers.ModelSerializer):
raise serializers.ValidationError(
'You do not have the permission to edit this playlist')
existing = value.playlist_tracks.count()
if existing >= settings.PLAYLISTS_MAX_TRACKS:
max_tracks = preferences.get('playlists__max_tracks')
if existing >= max_tracks:
raise serializers.ValidationError(
'Playlist has reached the maximum of {} tracks'.format(
settings.PLAYLISTS_MAX_TRACKS))
max_tracks))
return value
@transaction.atomic

View File

@ -55,8 +55,6 @@ class RadioSession(models.Model):
related_object = GenericForeignKey('related_object_content_type', 'related_object_id')
def save(self, **kwargs):
if not self.user and not self.session_key:
raise ValidationError('Cannot have both session_key and user empty for radio session')
self.radio.clean(self)
super().save(**kwargs)

View File

@ -38,6 +38,7 @@ class RadioSerializer(serializers.ModelSerializer):
return super().save(**kwargs)
class RadioSessionTrackSerializerCreate(serializers.ModelSerializer):
class Meta:
model = models.RadioSessionTrack
@ -62,17 +63,14 @@ class RadioSessionSerializer(serializers.ModelSerializer):
'user',
'creation_date',
'custom_radio',
'session_key')
)
def validate(self, data):
registry[data['radio_type']]().validate_session(data, **self.context)
return data
def create(self, validated_data):
if self.context.get('user'):
validated_data['user'] = self.context.get('user')
else:
validated_data['session_key'] = self.context['session_key']
validated_data['user'] = self.context['user']
if validated_data.get('related_object_id'):
radio = registry[validated_data['radio_type']]()
validated_data['related_object'] = radio.get_related_object(validated_data['related_object_id'])

View File

@ -2,6 +2,7 @@ from django.db.models import Q
from django.http import Http404
from rest_framework import generics, mixins, viewsets
from rest_framework import permissions
from rest_framework import status
from rest_framework.response import Response
from rest_framework.decorators import detail_route, list_route
@ -24,7 +25,7 @@ class RadioViewSet(
viewsets.GenericViewSet):
serializer_class = serializers.RadioSerializer
permission_classes = [ConditionalAuthentication]
permission_classes = [permissions.IsAuthenticated]
filter_class = filtersets.RadioFilter
def get_queryset(self):
@ -84,21 +85,15 @@ class RadioSessionViewSet(mixins.CreateModelMixin,
serializer_class = serializers.RadioSessionSerializer
queryset = models.RadioSession.objects.all()
permission_classes = [ConditionalAuthentication]
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
queryset = super().get_queryset()
if self.request.user.is_authenticated:
return queryset.filter(user=self.request.user)
else:
return queryset.filter(session_key=self.request.session.session_key)
return queryset.filter(user=self.request.user)
def get_serializer_context(self):
context = super().get_serializer_context()
if self.request.user.is_authenticated:
context['user'] = self.request.user
else:
context['session_key'] = self.request.session.session_key
context['user'] = self.request.user
return context
@ -106,17 +101,14 @@ class RadioSessionTrackViewSet(mixins.CreateModelMixin,
viewsets.GenericViewSet):
serializer_class = serializers.RadioSessionTrackSerializer
queryset = models.RadioSessionTrack.objects.all()
permission_classes = [ConditionalAuthentication]
permission_classes = [permissions.IsAuthenticated]
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
session = serializer.validated_data['session']
try:
if request.user.is_authenticated:
assert request.user == session.user
else:
assert request.session.session_key == session.session_key
assert request.user == session.user
except AssertionError:
return Response(status=status.HTTP_403_FORBIDDEN)
track = session.radio.pick()

View File

@ -1,10 +1,18 @@
import django_filters
from funkwhale_api.common import fields
from . import models
class ImportRequestFilter(django_filters.FilterSet):
q = fields.SearchFilter(search_fields=[
'artist_name',
'user__username',
'albums',
'comment',
])
class Meta:
model = models.ImportRequest
fields = {

View File

@ -15,6 +15,7 @@ STATUS_CHOICES = [
('closed', 'closed'),
]
class ImportRequest(models.Model):
creation_date = models.DateTimeField(default=timezone.now)
imported_date = models.DateTimeField(null=True, blank=True)

View File

@ -0,0 +1,8 @@
{% load account %}{% user_display user as user_display %}{% load i18n %}{% autoescape off %}{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}Hello from {{ site_name }}!
You're receiving this e-mail because user {{ user_display }} at {{ site_domain }} has given yours as an e-mail address to connect their account.
To confirm this is correct, go to {{ funkwhale_url }}/auth/email/confirm?key={{ key }}
{% endblocktrans %}{% endautoescape %}
{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}Thank you from {{ site_name }}!
{{ site_domain }}{% endblocktrans %}

View File

@ -0,0 +1,12 @@
{% load i18n %}{% autoescape off %}
{% blocktrans %}You're receiving this email because you requested a password reset for your user account at {{ site_name }}.{% endblocktrans %}
{% trans "Please go to the following page and choose a new password:" %}
{{ funkwhale_url }}/auth/password/reset/confirm?uid={{ uid }}&token={{ token }}
{% trans "Your username, in case you've forgotten:" %} {{ user.get_username }}
{% trans "Thanks for using our site!" %}
{% blocktrans %}The {{ site_name }} team{% endblocktrans %}
{% endautoescape %}

View File

@ -1,5 +1,6 @@
from allauth.account.adapter import DefaultAccountAdapter
from django.conf import settings
from allauth.account.adapter import DefaultAccountAdapter
from dynamic_preferences.registries import global_preferences_registry
@ -8,3 +9,7 @@ class FunkwhaleAccountAdapter(DefaultAccountAdapter):
def is_open_for_signup(self, request):
manager = global_preferences_registry.manager()
return manager['users__registration_enabled']
def send_mail(self, template_prefix, email, context):
context['funkwhale_url'] = settings.FUNKWHALE_URL
return super().send_mail(template_prefix, email, context)

View File

@ -1,11 +0,0 @@
class AnonymousSessionMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if not request.session.session_key:
request.session.save()
response = self.get_response(request)
return response

View File

@ -1,16 +1,20 @@
from django.views.generic import TemplateView
from django.conf.urls import url
from rest_auth.registration.views import VerifyEmailView
from rest_auth.views import PasswordChangeView
from rest_auth.registration import views as registration_views
from rest_auth import views as rest_auth_views
from .views import RegisterView
from . import views
urlpatterns = [
url(r'^$', RegisterView.as_view(), name='rest_register'),
url(r'^verify-email/$', VerifyEmailView.as_view(), name='rest_verify_email'),
url(r'^change-password/$', PasswordChangeView.as_view(), name='change_password'),
url(r'^$', views.RegisterView.as_view(), name='rest_register'),
url(r'^verify-email/$',
registration_views.VerifyEmailView.as_view(),
name='rest_verify_email'),
url(r'^change-password/$',
rest_auth_views.PasswordChangeView.as_view(),
name='change_password'),
# This url is used by django-allauth and empty TemplateView is
# defined just to allow reverse() call inside app, for example when email

View File

@ -1,5 +1,7 @@
from rest_framework import serializers
from django.conf import settings
from rest_framework import serializers
from rest_auth.serializers import PasswordResetSerializer as PRS
from funkwhale_api.activity import serializers as activity_serializers
from . import models
@ -63,3 +65,12 @@ class UserReadSerializer(serializers.ModelSerializer):
'status': o.has_perm(internal_codename)
}
return perms
class PasswordResetSerializer(PRS):
def get_email_options(self):
return {
'extra_email_context': {
'funkwhale_url': settings.FUNKWHALE_URL
}
}

View File

@ -11,7 +11,7 @@ python_files = tests.py test_*.py *_tests.py
testpaths = tests
env =
SECRET_KEY=test
DJANGO_EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
EMAIL_CONFIG=consolemail://
CELERY_BROKER_URL=memory://
CELERY_TASK_ALWAYS_EAGER=True
CACHEOPS_ENABLED=False

View File

@ -4,8 +4,8 @@ from funkwhale_api.activity import serializers
from funkwhale_api.activity import utils
def test_activity_view(factories, api_client, settings, anonymous_user):
settings.API_AUTHENTICATION_REQUIRED = False
def test_activity_view(factories, api_client, preferences, anonymous_user):
preferences['common__api_authentication_required'] = False
favorite = factories['favorites.TrackFavorite'](
user__privacy_level='everyone')
listening = factories['history.Listening']()

View File

@ -27,12 +27,19 @@ def factories_autodiscover():
@pytest.fixture(autouse=True)
def cache():
"""
Returns a django Cache instance for cache-related operations
"""
yield django_cache
django_cache.clear()
@pytest.fixture
def factories(db):
"""
Returns a dictionnary containing all registered factories with keys such as
users.User or music.Track
"""
from funkwhale_api import factories
for v in factories.registry.values():
try:
@ -45,6 +52,10 @@ def factories(db):
@pytest.fixture
def nodb_factories():
"""
Returns a dictionnary containing all registered factories with a build strategy
that does not require access to the database
"""
from funkwhale_api import factories
for v in factories.registry.values():
try:
@ -57,6 +68,9 @@ def nodb_factories():
@pytest.fixture
def preferences(db, cache):
"""
return a dynamic_preferences manager for global_preferences
"""
manager = global_preferences_registry.manager()
manager.all()
yield manager
@ -64,6 +78,10 @@ def preferences(db, cache):
@pytest.fixture
def tmpdir():
"""
Returns a temporary directory path where you can write things during your
test
"""
d = tempfile.mkdtemp()
yield d
shutil.rmtree(d)
@ -71,11 +89,18 @@ def tmpdir():
@pytest.fixture
def tmpfile():
"""
Returns a temporary file where you can write things during your test
"""
yield tempfile.NamedTemporaryFile()
@pytest.fixture
def logged_in_client(db, factories, client):
"""
Returns a logged-in, non-API client with an authenticated ``User``
stored in the ``user`` attribute
"""
user = factories['users.User']()
assert client.login(username=user.username, password='test')
setattr(client, 'user', user)
@ -85,16 +110,24 @@ def logged_in_client(db, factories, client):
@pytest.fixture
def anonymous_user():
"""Returns a AnonymousUser() instance"""
return AnonymousUser()
@pytest.fixture
def api_client(client):
"""
Return an API client without any authentication
"""
return APIClient()
@pytest.fixture
def logged_in_api_client(db, factories, api_client):
"""
Return a logged-in API client with an authenticated ``User``
stored in the ``user`` attribute
"""
user = factories['users.User']()
assert api_client.login(username=user.username, password='test')
setattr(api_client, 'user', user)
@ -104,6 +137,10 @@ def logged_in_api_client(db, factories, api_client):
@pytest.fixture
def superuser_api_client(db, factories, api_client):
"""
Return a logged-in API client with an authenticated superuser
stored in the ``user`` attribute
"""
user = factories['users.SuperUser']()
assert api_client.login(username=user.username, password='test')
setattr(api_client, 'user', user)
@ -113,6 +150,10 @@ def superuser_api_client(db, factories, api_client):
@pytest.fixture
def superuser_client(db, factories, client):
"""
Return a logged-in, non-API client with an authenticated ``User``
stored in the ``user`` attribute
"""
user = factories['users.SuperUser']()
assert client.login(username=user.username, password='test')
setattr(client, 'user', user)
@ -122,11 +163,17 @@ def superuser_client(db, factories, client):
@pytest.fixture
def api_request():
"""
Returns a dummy API request object you can pass to API views
"""
return APIRequestFactory()
@pytest.fixture
def fake_request():
"""
Returns a dummy, non-API request object you can pass to regular views
"""
return client.RequestFactory()
@ -140,16 +187,6 @@ def activity_registry():
record.registry[key] = value
@pytest.fixture
def activity_registry():
r = record.registry
state = list(record.registry.items())
yield record.registry
record.registry.clear()
for key, value in state:
record.registry[key] = value
@pytest.fixture
def activity_muted(activity_registry, mocker):
yield mocker.patch.object(record, 'send')
@ -157,6 +194,9 @@ def activity_muted(activity_registry, mocker):
@pytest.fixture(autouse=True)
def media_root(settings):
"""
Sets settings.MEDIA_ROOT to a temporary path and returns this path
"""
tmp_dir = tempfile.mkdtemp()
settings.MEDIA_ROOT = tmp_dir
yield settings.MEDIA_ROOT
@ -165,12 +205,19 @@ def media_root(settings):
@pytest.fixture
def r_mock():
"""
Returns a requests_mock.mock() object you can use to mock HTTP calls made
using python-requests
"""
with requests_mock.mock() as m:
yield m
@pytest.fixture
def authenticated_actor(factories, mocker):
"""
Returns an authenticated ActivityPub actor
"""
actor = factories['federation.Actor']()
mocker.patch(
'funkwhale_api.federation.authentication.SignatureAuthentication.authenticate_actor',

View File

@ -99,8 +99,8 @@ def test_user_can_remove_favorite_via_api_using_track_id(
@pytest.mark.parametrize('url,method', [
('api:v1:favorites:tracks-list', 'get'),
])
def test_url_require_auth(url, method, db, settings, client):
settings.API_AUTHENTICATION_REQUIRED = True
def test_url_require_auth(url, method, db, preferences, client):
preferences['common__api_authentication_required'] = True
url = reverse(url)
response = getattr(client, method)(url)
assert response.status_code == 401

View File

@ -29,6 +29,42 @@ def test_actor_fetching(r_mock):
assert r == payload
def test_get_actor(factories, r_mock):
actor = factories['federation.Actor'].build()
payload = serializers.ActorSerializer(actor).data
r_mock.get(actor.url, json=payload)
new_actor = actors.get_actor(actor.url)
assert new_actor.pk is not None
assert serializers.ActorSerializer(new_actor).data == payload
def test_get_actor_use_existing(factories, preferences, mocker):
preferences['federation__actor_fetch_delay'] = 60
actor = factories['federation.Actor']()
get_data = mocker.patch('funkwhale_api.federation.actors.get_actor_data')
new_actor = actors.get_actor(actor.url)
assert new_actor == actor
get_data.assert_not_called()
def test_get_actor_refresh(factories, preferences, mocker):
preferences['federation__actor_fetch_delay'] = 0
actor = factories['federation.Actor']()
payload = serializers.ActorSerializer(actor).data
# actor changed their username in the meantime
payload['preferredUsername'] = 'New me'
get_data = mocker.patch(
'funkwhale_api.federation.actors.get_actor_data',
return_value=payload)
new_actor = actors.get_actor(actor.url)
assert new_actor == actor
assert new_actor.last_fetch_date > actor.last_fetch_date
assert new_actor.preferred_username == 'New me'
def test_get_library(db, settings, mocker):
get_key_pair = mocker.patch(
'funkwhale_api.federation.keys.get_key_pair',
@ -238,9 +274,9 @@ def test_actor_is_system(
@pytest.mark.parametrize('value', [False, True])
def test_library_actor_manually_approves_based_on_setting(
value, settings):
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = value
def test_library_actor_manually_approves_based_on_preference(
value, preferences):
preferences['federation__music_needs_approval'] = value
library_conf = actors.SYSTEM_ACTORS['library']
assert library_conf.manually_approves_followers is value
@ -320,8 +356,8 @@ def test_test_actor_handles_undo_follow(
def test_library_actor_handles_follow_manual_approval(
settings, mocker, factories):
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = True
preferences, mocker, factories):
preferences['federation__music_needs_approval'] = True
actor = factories['federation.Actor']()
now = timezone.now()
mocker.patch('django.utils.timezone.now', return_value=now)
@ -341,8 +377,8 @@ def test_library_actor_handles_follow_manual_approval(
def test_library_actor_handles_follow_auto_approval(
settings, mocker, factories):
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = False
preferences, mocker, factories):
preferences['federation__music_needs_approval'] = False
actor = factories['federation.Actor']()
accept_follow = mocker.patch(
'funkwhale_api.federation.activity.accept_follow')

View File

@ -5,8 +5,8 @@ from funkwhale_api.federation import permissions
def test_library_follower(
factories, api_request, anonymous_user, settings):
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = True
factories, api_request, anonymous_user, preferences):
preferences['federation__music_needs_approval'] = True
view = APIView.as_view()
permission = permissions.LibraryFollower()
request = api_request.get('/')
@ -17,8 +17,8 @@ def test_library_follower(
def test_library_follower_actor_non_follower(
factories, api_request, anonymous_user, settings):
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = True
factories, api_request, anonymous_user, preferences):
preferences['federation__music_needs_approval'] = True
actor = factories['federation.Actor']()
view = APIView.as_view()
permission = permissions.LibraryFollower()
@ -31,8 +31,8 @@ def test_library_follower_actor_non_follower(
def test_library_follower_actor_follower_not_approved(
factories, api_request, anonymous_user, settings):
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = True
factories, api_request, anonymous_user, preferences):
preferences['federation__music_needs_approval'] = True
library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
follow = factories['federation.Follow'](target=library, approved=False)
view = APIView.as_view()
@ -46,8 +46,8 @@ def test_library_follower_actor_follower_not_approved(
def test_library_follower_actor_follower(
factories, api_request, anonymous_user, settings):
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = True
factories, api_request, anonymous_user, preferences):
preferences['federation__music_needs_approval'] = True
library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
follow = factories['federation.Follow'](target=library, approved=True)
view = APIView.as_view()

View File

@ -1,4 +1,7 @@
import datetime
import os
import pathlib
import pytest
from django.core.paginator import Paginator
from django.utils import timezone
@ -117,17 +120,16 @@ def test_clean_federation_music_cache_if_no_listen(preferences, factories):
lt1 = factories['federation.LibraryTrack'](with_audio_file=True)
lt2 = factories['federation.LibraryTrack'](with_audio_file=True)
lt3 = factories['federation.LibraryTrack'](with_audio_file=True)
tf1 = factories['music.TrackFile'](library_track=lt1)
tf2 = factories['music.TrackFile'](library_track=lt2)
tf3 = factories['music.TrackFile'](library_track=lt3)
# we listen to the first one, and the second one (but weeks ago)
listening1 = factories['history.Listening'](
track=tf1.track,
creation_date=timezone.now())
listening2 = factories['history.Listening'](
track=tf2.track,
creation_date=timezone.now() - datetime.timedelta(minutes=61))
tf1 = factories['music.TrackFile'](
accessed_date=timezone.now(), library_track=lt1)
tf2 = factories['music.TrackFile'](
accessed_date=timezone.now()-datetime.timedelta(minutes=61),
library_track=lt2)
tf3 = factories['music.TrackFile'](
accessed_date=None, library_track=lt3)
path1 = lt1.audio_file.path
path2 = lt2.audio_file.path
path3 = lt3.audio_file.path
tasks.clean_music_cache()
@ -138,3 +140,32 @@ def test_clean_federation_music_cache_if_no_listen(preferences, factories):
assert bool(lt1.audio_file) is True
assert bool(lt2.audio_file) is False
assert bool(lt3.audio_file) is False
assert os.path.exists(path1) is True
assert os.path.exists(path2) is False
assert os.path.exists(path3) is False
def test_clean_federation_music_cache_orphaned(
settings, preferences, factories):
preferences['federation__music_cache_duration'] = 60
path = os.path.join(settings.MEDIA_ROOT, 'federation_cache')
keep_path = os.path.join(os.path.join(path, '1a', 'b2'), 'keep.ogg')
remove_path = os.path.join(os.path.join(path, 'c3', 'd4'), 'remove.ogg')
os.makedirs(os.path.dirname(keep_path), exist_ok=True)
os.makedirs(os.path.dirname(remove_path), exist_ok=True)
pathlib.Path(keep_path).touch()
pathlib.Path(remove_path).touch()
lt = factories['federation.LibraryTrack'](
with_audio_file=True,
audio_file__path=keep_path)
tf = factories['music.TrackFile'](
library_track=lt,
accessed_date=timezone.now())
tasks.clean_music_cache()
lt.refresh_from_db()
assert bool(lt.audio_file) is True
assert os.path.exists(lt.audio_file.path) is True
assert os.path.exists(remove_path) is False

View File

@ -13,7 +13,7 @@ from funkwhale_api.federation import webfinger
@pytest.mark.parametrize('system_actor', actors.SYSTEM_ACTORS.keys())
def test_instance_actors(system_actor, db, settings, api_client):
def test_instance_actors(system_actor, db, api_client):
actor = actors.SYSTEM_ACTORS[system_actor].get_actor_instance()
url = reverse(
'federation:instance-actors-detail',
@ -34,8 +34,8 @@ def test_instance_actors(system_actor, db, settings, api_client):
('well-known-webfinger', {}),
])
def test_instance_endpoints_405_if_federation_disabled(
authenticated_actor, db, settings, api_client, route, kwargs):
settings.FEDERATION_ENABLED = False
authenticated_actor, db, preferences, api_client, route, kwargs):
preferences['federation__enabled'] = False
url = reverse('federation:{}'.format(route), kwargs=kwargs)
response = api_client.get(url)
@ -71,8 +71,8 @@ def test_wellknown_webfinger_system(
def test_audio_file_list_requires_authenticated_actor(
db, settings, api_client):
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = True
db, preferences, api_client):
preferences['federation__music_needs_approval'] = True
url = reverse('federation:music:files-list')
response = api_client.get(url)
@ -80,9 +80,9 @@ def test_audio_file_list_requires_authenticated_actor(
def test_audio_file_list_actor_no_page(
db, settings, api_client, factories):
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = False
settings.FEDERATION_COLLECTION_PAGE_SIZE = 2
db, preferences, api_client, factories):
preferences['federation__music_needs_approval'] = False
preferences['federation__collection_page_size'] = 2
library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
tfs = factories['music.TrackFile'].create_batch(size=5)
conf = {
@ -101,9 +101,9 @@ def test_audio_file_list_actor_no_page(
def test_audio_file_list_actor_page(
db, settings, api_client, factories):
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = False
settings.FEDERATION_COLLECTION_PAGE_SIZE = 2
db, preferences, api_client, factories):
preferences['federation__music_needs_approval'] = False
preferences['federation__collection_page_size'] = 2
library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
tfs = factories['music.TrackFile'].create_batch(size=5)
conf = {
@ -121,8 +121,8 @@ def test_audio_file_list_actor_page(
def test_audio_file_list_actor_page_exclude_federated_files(
db, settings, api_client, factories):
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = False
db, preferences, api_client, factories):
preferences['federation__music_needs_approval'] = False
library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
tfs = factories['music.TrackFile'].create_batch(size=5, federation=True)
@ -134,8 +134,8 @@ def test_audio_file_list_actor_page_exclude_federated_files(
def test_audio_file_list_actor_page_error(
db, settings, api_client, factories):
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = False
db, preferences, api_client, factories):
preferences['federation__music_needs_approval'] = False
url = reverse('federation:music:files-list')
response = api_client.get(url, data={'page': 'nope'})
@ -143,15 +143,15 @@ def test_audio_file_list_actor_page_error(
def test_audio_file_list_actor_page_error_too_far(
db, settings, api_client, factories):
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = False
db, preferences, api_client, factories):
preferences['federation__music_needs_approval'] = False
url = reverse('federation:music:files-list')
response = api_client.get(url, data={'page': 5000})
assert response.status_code == 404
def test_library_actor_includes_library_link(db, settings, api_client):
def test_library_actor_includes_library_link(db, preferences, api_client):
actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
url = reverse(
'federation:instance-actors-detail',

View File

@ -14,20 +14,6 @@ def test_can_create_listening(factories):
l = models.Listening.objects.create(user=user, track=track)
def test_anonymous_user_can_create_listening_via_api(client, factories, settings):
settings.API_AUTHENTICATION_REQUIRED = False
track = factories['music.Track']()
url = reverse('api:v1:history:listenings-list')
response = client.post(url, {
'track': track.pk,
})
listening = models.Listening.objects.latest('id')
assert listening.track == track
assert listening.session_key == client.session.session_key
def test_logged_in_user_can_create_listening_via_api(
logged_in_client, factories, activity_muted):
track = factories['music.Track']()

File diff suppressed because one or more lines are too long

View File

@ -265,16 +265,16 @@ def test_can_search_tracks(factories, logged_in_client):
('api:v1:albums-list', 'get'),
])
def test_can_restrict_api_views_to_authenticated_users(
db, route, method, settings, client):
db, route, method, preferences, client):
url = reverse(route)
settings.API_AUTHENTICATION_REQUIRED = True
preferences['common__api_authentication_required'] = True
response = getattr(client, method)(url)
assert response.status_code == 401
def test_track_file_url_is_restricted_to_authenticated_users(
api_client, factories, settings):
settings.API_AUTHENTICATION_REQUIRED = True
api_client, factories, preferences):
preferences['common__api_authentication_required'] = True
f = factories['music.TrackFile']()
assert f.audio_file is not None
url = f.path
@ -283,8 +283,8 @@ def test_track_file_url_is_restricted_to_authenticated_users(
def test_track_file_url_is_accessible_to_authenticated_users(
logged_in_api_client, factories, settings):
settings.API_AUTHENTICATION_REQUIRED = True
logged_in_api_client, factories, preferences):
preferences['common__api_authentication_required'] = True
f = factories['music.TrackFile']()
assert f.audio_file is not None
url = f.path

View File

@ -169,10 +169,10 @@ def test_import_job_run_triggers_notifies_followers(
def test_import_batch_notifies_followers_skip_on_disabled_federation(
settings, factories, mocker):
preferences, factories, mocker):
mocked_deliver = mocker.patch('funkwhale_api.federation.activity.deliver')
batch = factories['music.ImportBatch'](finished=True)
settings.FEDERATION_ENABLED = False
preferences['federation__enabled'] = False
tasks.import_batch_notify_followers(import_batch_id=batch.pk)
mocked_deliver.assert_not_called()
@ -243,3 +243,4 @@ def test__do_import_in_place_mbid(factories, tmpfile):
assert bool(tf.audio_file) is False
assert tf.source == 'file:///test.ogg'
assert tf.mimetype == 'audio/ogg'

View File

@ -13,7 +13,7 @@ def test_list_permission_no_protect(anonymous_user, api_request, settings):
def test_list_permission_protect_anonymous(
anonymous_user, api_request, settings):
db, anonymous_user, api_request, settings):
settings.PROTECT_AUDIO_FILES = True
view = APIView.as_view()
permission = permissions.Listen()

View File

@ -2,6 +2,7 @@ import io
import pytest
from django.urls import reverse
from django.utils import timezone
from funkwhale_api.music import views
from funkwhale_api.federation import actors
@ -76,29 +77,60 @@ def test_can_serve_track_file_as_remote_library_deny_not_following(
assert response.status_code == 403
def test_serve_file_apache(factories, api_client, settings):
@pytest.mark.parametrize('proxy,serve_path,expected', [
('apache2', '/host/music', '/host/music/hello/world.mp3'),
('apache2', '/app/music', '/app/music/hello/world.mp3'),
('nginx', '/host/music', '/_protected/music/hello/world.mp3'),
('nginx', '/app/music', '/_protected/music/hello/world.mp3'),
])
def test_serve_file_in_place(
proxy, serve_path, expected, factories, api_client, settings):
headers = {
'apache2': 'X-Sendfile',
'nginx': 'X-Accel-Redirect',
}
settings.PROTECT_AUDIO_FILES = False
settings.REVERSE_PROXY_TYPE = 'apache2'
tf = factories['music.TrackFile']()
settings.PROTECT_FILE_PATH = '/_protected/music'
settings.REVERSE_PROXY_TYPE = proxy
settings.MUSIC_DIRECTORY_PATH = '/app/music'
settings.MUSIC_DIRECTORY_SERVE_PATH = serve_path
tf = factories['music.TrackFile'](
in_place=True,
source='file:///app/music/hello/world.mp3'
)
response = api_client.get(tf.path)
assert response.status_code == 200
assert response['X-Sendfile'] == tf.audio_file.path
assert response[headers[proxy]] == expected
def test_serve_file_apache_in_place(factories, api_client, settings):
@pytest.mark.parametrize('proxy,serve_path,expected', [
('apache2', '/host/music', '/host/media/tracks/hello/world.mp3'),
# apache with container not supported yet
# ('apache2', '/app/music', '/app/music/tracks/hello/world.mp3'),
('nginx', '/host/music', '/_protected/media/tracks/hello/world.mp3'),
('nginx', '/app/music', '/_protected/media/tracks/hello/world.mp3'),
])
def test_serve_file_media(
proxy, serve_path, expected, factories, api_client, settings):
headers = {
'apache2': 'X-Sendfile',
'nginx': 'X-Accel-Redirect',
}
settings.PROTECT_AUDIO_FILES = False
settings.REVERSE_PROXY_TYPE = 'apache2'
settings.MUSIC_DIRECTORY_PATH = '/music'
settings.MUSIC_DIRECTORY_SERVE_PATH = '/host/music'
track_file = factories['music.TrackFile'](
in_place=True,
source='file:///music/test.ogg')
settings.MEDIA_ROOT = '/host/media'
settings.PROTECT_FILE_PATH = '/_protected/music'
settings.REVERSE_PROXY_TYPE = proxy
settings.MUSIC_DIRECTORY_PATH = '/app/music'
settings.MUSIC_DIRECTORY_SERVE_PATH = serve_path
response = api_client.get(track_file.path)
tf = factories['music.TrackFile']()
tf.__class__.objects.filter(pk=tf.pk).update(
audio_file='tracks/hello/world.mp3')
response = api_client.get(tf.path)
assert response.status_code == 200
assert response['X-Sendfile'] == '/host/music/test.ogg'
assert response[headers[proxy]] == expected
def test_can_proxy_remote_track(
@ -118,23 +150,17 @@ def test_can_proxy_remote_track(
assert library_track.audio_file.read() == b'test'
def test_can_serve_in_place_imported_file(
factories, settings, api_client, r_mock):
def test_serve_updates_access_date(factories, settings, api_client):
settings.PROTECT_AUDIO_FILES = False
settings.MUSIC_DIRECTORY_SERVE_PATH = '/host/music'
settings.MUSIC_DIRECTORY_PATH = '/music'
settings.MUSIC_DIRECTORY_PATH = '/music'
track_file = factories['music.TrackFile'](
in_place=True,
source='file:///music/test.ogg')
track_file = factories['music.TrackFile']()
now = timezone.now()
assert track_file.accessed_date is None
response = api_client.get(track_file.path)
track_file.refresh_from_db()
assert response.status_code == 200
assert response['X-Accel-Redirect'] == '{}{}'.format(
settings.PROTECT_FILES_PATH,
'/music/host/music/test.ogg'
)
assert track_file.accessed_date > now
def test_can_create_import_from_federation_tracks(
@ -196,3 +222,64 @@ def test_import_job_stats_filter(factories, superuser_api_client):
}
assert response.status_code == 200
assert response.data == expected
def test_import_job_run_via_api(factories, superuser_api_client, mocker):
run = mocker.patch('funkwhale_api.music.tasks.import_job_run.delay')
job1 = factories['music.ImportJob'](status='errored')
job2 = factories['music.ImportJob'](status='pending')
url = reverse('api:v1:import-jobs-run')
response = superuser_api_client.post(url, {'jobs': [job2.pk, job1.pk]})
job1.refresh_from_db()
job2.refresh_from_db()
assert response.status_code == 200
assert response.data == {'jobs': [job1.pk, job2.pk]}
assert job1.status == 'pending'
assert job2.status == 'pending'
run.assert_any_call(import_job_id=job1.pk)
run.assert_any_call(import_job_id=job2.pk)
def test_import_batch_run_via_api(factories, superuser_api_client, mocker):
run = mocker.patch('funkwhale_api.music.tasks.import_job_run.delay')
batch = factories['music.ImportBatch']()
job1 = factories['music.ImportJob'](batch=batch, status='errored')
job2 = factories['music.ImportJob'](batch=batch, status='pending')
url = reverse('api:v1:import-jobs-run')
response = superuser_api_client.post(url, {'batches': [batch.pk]})
job1.refresh_from_db()
job2.refresh_from_db()
assert response.status_code == 200
assert job1.status == 'pending'
assert job2.status == 'pending'
run.assert_any_call(import_job_id=job1.pk)
run.assert_any_call(import_job_id=job2.pk)
def test_import_batch_and_job_run_via_api(
factories, superuser_api_client, mocker):
run = mocker.patch('funkwhale_api.music.tasks.import_job_run.delay')
batch = factories['music.ImportBatch']()
job1 = factories['music.ImportJob'](batch=batch, status='errored')
job2 = factories['music.ImportJob'](status='pending')
url = reverse('api:v1:import-jobs-run')
response = superuser_api_client.post(
url, {'batches': [batch.pk], 'jobs': [job2.pk]})
job1.refresh_from_db()
job2.refresh_from_db()
assert response.status_code == 200
assert job1.status == 'pending'
assert job2.status == 'pending'
run.assert_any_call(import_job_id=job1.pk)
run.assert_any_call(import_job_id=job2.pk)

View File

@ -116,8 +116,8 @@ def test_can_insert_many(factories):
assert plt.playlist == playlist
def test_insert_many_honor_max_tracks(factories, settings):
settings.PLAYLISTS_MAX_TRACKS = 4
def test_insert_many_honor_max_tracks(preferences, factories):
preferences['playlists__max_tracks'] = 4
playlist = factories['playlists.Playlist']()
plts = factories['playlists.PlaylistTrack'].create_batch(
size=2, playlist=playlist)

View File

@ -2,8 +2,8 @@ from funkwhale_api.playlists import models
from funkwhale_api.playlists import serializers
def test_cannot_max_500_tracks_per_playlist(factories, settings):
settings.PLAYLISTS_MAX_TRACKS = 2
def test_cannot_max_500_tracks_per_playlist(factories, preferences):
preferences['playlists__max_tracks'] = 2
playlist = factories['playlists.Playlist']()
plts = factories['playlists.PlaylistTrack'].create_batch(
size=2, playlist=playlist)

View File

@ -107,8 +107,8 @@ def test_deleting_plt_updates_indexes(
@pytest.mark.parametrize('level', ['instance', 'me', 'followers'])
def test_playlist_privacy_respected_in_list_anon(
settings, level, factories, api_client):
settings.API_AUTHENTICATION_REQUIRED = False
preferences, level, factories, api_client):
preferences['common__api_authentication_required'] = False
factories['playlists.Playlist'](privacy_level=level)
url = reverse('api:v1:playlists-list')
response = api_client.get(url)
@ -137,8 +137,8 @@ def test_only_owner_can_edit_playlist_track(
@pytest.mark.parametrize('level', ['instance', 'me', 'followers'])
def test_playlist_track_privacy_respected_in_list_anon(
level, factories, api_client, settings):
settings.API_AUTHENTICATION_REQUIRED = False
level, factories, api_client, preferences):
preferences['common__api_authentication_required'] = False
factories['playlists.PlaylistTrack'](playlist__privacy_level=level)
url = reverse('api:v1:playlist-tracks-list')
response = api_client.get(url)

View File

@ -151,20 +151,6 @@ def test_can_start_radio_for_logged_in_user(logged_in_client):
assert session.user == logged_in_client.user
def test_can_start_radio_for_anonymous_user(api_client, db, settings):
settings.API_AUTHENTICATION_REQUIRED = False
url = reverse('api:v1:radios:sessions-list')
response = api_client.post(url, {'radio_type': 'random'})
assert response.status_code == 201
session = models.RadioSession.objects.latest('id')
assert session.radio_type == 'random'
assert session.user is None
assert session.session_key == api_client.session.session_key
def test_can_get_track_for_session_from_api(factories, logged_in_client):
files = factories['music.TrackFile'].create_batch(1)
tracks = [f.track for f in files]
@ -227,25 +213,25 @@ def test_can_start_tag_radio(factories):
radio = radios.TagRadio()
session = radio.start_session(user, related_object=tag)
assert session.radio_type =='tag'
assert session.radio_type == 'tag'
for i in range(5):
assert radio.pick() in good_tracks
def test_can_start_artist_radio_from_api(api_client, settings, factories):
settings.API_AUTHENTICATION_REQUIRED = False
def test_can_start_artist_radio_from_api(
logged_in_api_client, preferences, factories):
artist = factories['music.Artist']()
url = reverse('api:v1:radios:sessions-list')
response = api_client.post(
response = logged_in_api_client.post(
url, {'radio_type': 'artist', 'related_object_id': artist.id})
assert response.status_code == 201
session = models.RadioSession.objects.latest('id')
assert session.radio_type, 'artist'
assert session.related_object, artist
assert session.radio_type == 'artist'
assert session.related_object == artist
def test_can_start_less_listened_radio(factories):
@ -257,6 +243,6 @@ def test_can_start_less_listened_radio(factories):
good_tracks = [f.track for f in good_files]
radio = radios.LessListenedRadio()
session = radio.start_session(user)
assert session.related_object == user
for i in range(5):
assert radio.pick() in good_tracks

View File

@ -5,9 +5,10 @@ jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
def test_can_authenticate_using_token_param_in_url(factories, settings, client):
def test_can_authenticate_using_token_param_in_url(
factories, preferences, client):
user = factories['users.User']()
settings.API_AUTHENTICATION_REQUIRED = True
preferences['common__api_authentication_required'] = True
url = reverse('api:v1:tracks-list')
response = client.get(url)

View File

@ -18,8 +18,8 @@ def test_can_get_search_results_from_youtube(mocker):
def test_can_get_search_results_from_funkwhale(
settings, mocker, api_client, db):
settings.API_AUTHENTICATION_REQUIRED = False
preferences, mocker, api_client, db):
preferences['common__api_authentication_required'] = False
mocker.patch(
'funkwhale_api.providers.youtube.client._do_search',
return_value=api_data.search['8 bit adventure'])
@ -70,8 +70,8 @@ def test_can_send_multiple_queries_at_once(mocker):
def test_can_send_multiple_queries_at_once_from_funwkhale(
settings, mocker, db, api_client):
settings.API_AUTHENTICATION_REQUIRED = False
preferences, mocker, db, api_client):
preferences['common__api_authentication_required'] = False
mocker.patch(
'funkwhale_api.providers.youtube.client._do_search',
return_value=api_data.search['8 bit adventure'])

View File

@ -136,6 +136,20 @@ def test_changing_password_updates_secret_key(logged_in_client):
assert user.password != password
def test_can_request_password_reset(
factories, api_client, mailoutbox):
user = factories['users.User']()
payload = {
'email': user.email,
}
emails = len(mailoutbox)
url = reverse('rest_password_reset')
response = api_client.post(url, payload)
assert response.status_code == 200
assert len(mailoutbox) > emails
def test_user_can_patch_his_own_settings(logged_in_api_client):
user = logged_in_api_client.user
payload = {

View File

@ -1,3 +1,6 @@
Upgrade instructions are available at https://docs.funkwhale.audio/upgrading.html
{% for section, _ in sections.items() %}
{% if sections[section] %}
{% for category, val in definitions.items() if category in sections[section]%}

122
deploy/apache.conf Normal file
View File

@ -0,0 +1,122 @@
# Following variables MUST be modified according to your setup
Define funkwhale-sn funkwhale.yourdomain.com
# Following variables should be modified according to your setup and if you
# use different configuration than what is described in our installation guide.
Define funkwhale-api http://localhost:5000
Define MUSIC_DIRECTORY_PATH /srv/funkwhale/data/music
# websockets are not working yet
# Define funkwhale-api-ws ws://localhost:5000
# HTTP request redirected to HTTPS
<VirtualHost *:80>
ServerName ${funkwhale-sn}
# Default is to force https
RewriteEngine on
RewriteCond %{SERVER_NAME} =${funkwhale-sn}
RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [END,QSA,R=permanent]
<Location "/.well-known/acme-challenge/">
Options None
Require all granted
</Location>
</VirtualHost>
<IfModule mod_ssl.c>
<VirtualHost *:443>
ServerName ${funkwhale-sn}
# Path to ErrorLog and access log
ErrorLog ${APACHE_LOG_DIR}/funkwhale/error.log
CustomLog ${APACHE_LOG_DIR}/funkwhale/access.log combined
# TLS
# Feel free to use your own configuration for SSL here or simply remove the
# lines and move the configuration to the previous server block if you
# don't want to run funkwhale behind https (this is not recommanded)
# have a look here for let's encrypt configuration:
# https://certbot.eff.org/all-instructions/#debian-9-stretch-nginx
SSLEngine on
SSLProxyEngine On
SSLCertificateFile /etc/letsencrypt/live/${funkwhale-sn}/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/${funkwhale-sn}/privkey.pem
Include /etc/letsencrypt/options-ssl-apache.conf
DocumentRoot /srv/funkwhale/front/dist
FallbackResource /index.html
# Configure Proxy settings
# ProxyPreserveHost pass the original Host header to the backend server
ProxyVia On
ProxyPreserveHost On
<IfModule mod_remoteip.c>
RemoteIPHeader X-Forwarded-For
</IfModule>
# Turning ProxyRequests on and allowing proxying from all may allow
# spammers to use your proxy to send email.
ProxyRequests Off
<Proxy *>
AddDefaultCharset off
Order Allow,Deny
Allow from all
</Proxy>
# Activating WebSockets (not working)
# ProxyPass "/api/v1/instance/activity" "ws://localhost:5000/api/v1/instance/activity"
<Location "/api">
# similar to nginx 'client_max_body_size 30M;'
LimitRequestBody 31457280
ProxyPass ${funkwhale-api}/api
ProxyPassReverse ${funkwhale-api}/api
</Location>
<Location "/federation">
ProxyPass ${funkwhale-api}/federation
ProxyPassReverse ${funkwhale-api}/federation
</Location>
<Location "/.well-known/webfinger">
ProxyPass ${funkwhale-api}/.well-known/webfinger
ProxyPassReverse ${funkwhale-api}/.well-known/webfinger
</Location>
Alias /media /srv/funkwhale/data/media
Alias /staticfiles /srv/funkwhale/data/static
# Setting appropriate access levels to serve frontend
<Directory "/srv/funkwhale/data/static">
Options FollowSymLinks
AllowOverride None
Require all granted
</Directory>
<Directory /srv/funkwhale/front/dist>
Options FollowSymLinks
AllowOverride None
Require all granted
</Directory>
# XSendFile is serving audio files
# WARNING : permissions on paths specified below overrides previous definition,
# everything under those paths is potentially exposed.
# Following directive may be needed to ensure xsendfile is loaded
#LoadModule xsendfile_module modules/mod_xsendfile.so
<IfModule mod_xsendfile.c>
XSendFile On
XSendFilePath /srv/funkwhale/data/media
XSendFilePath ${MUSIC_DIRECTORY_PATH}
SetEnv MOD_X_SENDFILE_ENABLED 1
</IfModule>
</VirtualHost>
</IfModule>

View File

@ -6,6 +6,7 @@
# - DJANGO_SECRET_KEY
# - DJANGO_ALLOWED_HOSTS
# - FUNKWHALE_URL
# - EMAIL_CONFIG and DEFAULT_FROM_EMAIL if you plan to send emails)
# On non-docker setup **only**, you'll also have to tweak/uncomment those variables:
# - DATABASE_URL
# - CACHE_URL
@ -41,6 +42,19 @@ FUNKWHALE_API_PORT=5000
# your instance
FUNKWHALE_URL=https://yourdomain.funwhale
# Configure email sending using this variale
# By default, funkwhale will output emails sent to stdout
# here are a few examples for this setting
# EMAIL_CONFIG=consolemail:// # output emails to console (the default)
# EMAIL_CONFIG=dummymail:// # disable email sending completely
# On a production instance, you'll usually want to use an external SMTP server:
# EMAIL_CONFIG=smtp://user@:password@youremail.host:25'
# EMAIL_CONFIG=smtp+ssl://user@:password@youremail.host:465'
# EMAIL_CONFIG=smtp+tls://user@:password@youremail.host:587'
# The email address to use to send systme emails. By default, we will
# DEFAULT_FROM_EMAIL=noreply@yourdomain
# Depending on the reverse proxy used in front of your funkwhale instance,
# the API will use different kind of headers to serve audio files
# Allowed values: nginx, apache2
@ -88,9 +102,6 @@ DJANGO_SECRET_KEY=
# want to
# DJANGO_ADMIN_URL=^api/admin/
# If True, unauthenticated users won't be able to query the API
API_AUTHENTICATION_REQUIRED=True
# Sentry/Raven error reporting (server side)
# Enable Raven if you want to help improve funkwhale by
# automatically sending error reports our Sentry instance.
@ -98,15 +109,6 @@ API_AUTHENTICATION_REQUIRED=True
RAVEN_ENABLED=false
RAVEN_DSN=https://44332e9fdd3d42879c7d35bf8562c6a4:0062dc16a22b41679cd5765e5342f716@sentry.eliotberriot.com/5
# This settings enable/disable federation on the instance level
FEDERATION_ENABLED=True
# This setting decide wether music library is shared automatically
# to followers or if it requires manual approval before.
# FEDERATION_MUSIC_NEEDS_APPROVAL=False
# means anyone can subscribe to your library and import your file,
# use with caution.
FEDERATION_MUSIC_NEEDS_APPROVAL=True
# In-place import settings
# You can safely leave those settings uncommented if you don't plan to use
# in place imports.

View File

@ -8,7 +8,7 @@ User=funkwhale
# adapt this depending on the path of your funkwhale installation
WorkingDirectory=/srv/funkwhale/api
EnvironmentFile=/srv/funkwhale/config/.env
ExecStart=/srv/funkwhale/virtualenv/bin/daphne -b ${FUNKWHALE_API_IP} -p ${FUNKWHALE_API_PORT} config.asgi:application
ExecStart=/srv/funkwhale/virtualenv/bin/daphne -b ${FUNKWHALE_API_IP} -p ${FUNKWHALE_API_PORT} config.asgi:application --proxy-headers
[Install]
WantedBy=multi-user.target

View File

@ -123,6 +123,15 @@ services:
- '35730:35730'
- '8001:8001'
api-docs:
image: swaggerapi/swagger-ui
environment:
- "API_URL=/swagger.yml"
ports:
- '8002:8080'
volumes:
- "./api/docs/swagger.yml:/usr/share/nginx/html/swagger.yml"
networks:
internal:
federation:

6
docs/api.rst Normal file
View File

@ -0,0 +1,6 @@
Funkwhale API
=============
Funkwhale API is still a work in progress and should not be considered as
stable. We offer an `interactive documentation using swagger </swagger/>`_
were you can browse available endpoints and try the API.

5
docs/build_docs.sh Executable file
View File

@ -0,0 +1,5 @@
#!/bin/bash -eux
# Building sphinx and swagger docs
python -m sphinx . $BUILD_PATH
TARGET_PATH="$BUILD_PATH/swagger" ./build_swagger.sh

9
docs/build_swagger.sh Executable file
View File

@ -0,0 +1,9 @@
#!/bin/bash -eux
SWAGGER_VERSION="3.13.6"
TARGET_PATH=${TARGET_PATH-"swagger"}
rm -rf $TARGET_PATH /tmp/swagger-ui
git clone --branch="v$SWAGGER_VERSION" --depth=1 "https://github.com/swagger-api/swagger-ui.git" /tmp/swagger-ui
mv /tmp/swagger-ui/dist $TARGET_PATH
cp swagger.yml $TARGET_PATH
sed -i "s,http://petstore.swagger.io/v2/swagger.json,swagger.yml,g" $TARGET_PATH/index.html

View File

@ -18,6 +18,8 @@ and technical aspects of your instance, such as database credentials.
on environment variables.
.. _instance-settings:
Instance settings
-----------------
@ -37,6 +39,38 @@ settings in this interface.
Configuration reference
-----------------------
.. _setting-EMAIL_CONFIG:
``EMAIL_CONFIG``
^^^^^^^^^^^^^^^^
Determine how emails are sent.
Default: ``consolemail://``
Possible values:
- ``consolemail://``: Output sent emails to stdout
- ``dummymail://``: Completely discard sent emails
- ``smtp://user:password@youremail.host:25``: Send emails via SMTP via youremail.host on port 25, without encryption, authenticating as user "user" with password "password"
- ``smtp+ssl://user:password@youremail.host:465``: Send emails via SMTP via youremail.host on port 465, using SSL encryption, authenticating as user "user" with password "password"
- ``smtp+tls://user:password@youremail.host:587``: Send emails via SMTP via youremail.host on port 587, using TLS encryption, authenticating as user "user" with password "password"
.. _setting-DEFAULT_FROM_EMAIL:
``DEFAULT_FROM_EMAIL``
^^^^^^^^^^^^^^^^^^^^^^
The email address to use to send email.
Default: ``Funkwhale <noreply@yourdomain>``
.. note::
Both the forms ``Funkwhale <noreply@yourdomain>`` and
``noreply@yourdomain`` work.
.. _setting-MUSIC_DIRECTORY_PATH:
``MUSIC_DIRECTORY_PATH``

1
docs/contributing.rst Normal file
View File

@ -0,0 +1 @@
.. include:: ../CONTRIBUTING

View File

@ -12,8 +12,7 @@ Managing federation
Federation management is only available to instance admins and users
who have the proper permissions. You can disable federation completely
at the instance level by setting the FEDERATION_ENABLED environment variable
to False.
at the instance level by editing the ``federation__enabled`` :ref:`setting <instance-settings>`.
On the front end, assuming you have the proper permission, you will see
a "Federation" link in the sidebar.
@ -52,6 +51,6 @@ each other instance asking for access to library. This is by design,
to ensure your library is not shared publicly without your consent.
However, if you're confident about federating publicly without manual approval,
you can set the FEDERATION_MUSIC_NEEDS_APPROVAL environment variable to false.
you can set the ``federation__music_needs_approval`` :ref:`setting <instance-settings>` to false.
Follow requests will be accepted automatically and followers
given access to your library without manual intervention.

View File

@ -16,7 +16,10 @@ Funkwhale is a self-hosted, modern free and open-source music server, heavily in
configuration
importing-music
federation
api
upgrading
third-party
contributing
changelog
Indices and tables

View File

@ -31,7 +31,7 @@ Layout
All funkwhale-related files will be located under ``/srv/funkwhale`` apart
from database files and a few configuration files. We will also have a
dedicated ``funwhale`` user to launch the processes we need and own those files.
dedicated ``funkwhale`` user to launch the processes we need and own those files.
You are free to use different values here, just remember to adapt those in the
next steps.

View File

@ -18,7 +18,7 @@ On debian-like systems, you would install the database server like this:
.. code-block:: shell
sudo apt-get install postgresql
sudo apt-get install postgresql postgresql-contrib
The remaining steps are heavily inspired from `this Digital Ocean guide <https://www.digitalocean.com/community/tutorials/how-to-set-up-django-with-postgres-nginx-and-gunicorn-on-ubuntu-16-04>`_.
@ -32,13 +32,22 @@ Create the project database and user:
.. code-block:: shell
CREATE DATABASE funkwhale;
CREATE DATABASE "scratch"
WITH ENCODING 'utf8'
LC_COLLATE = 'en_US.utf8'
LC_CTYPE = 'en_US.utf8';
CREATE USER funkwhale;
GRANT ALL PRIVILEGES ON DATABASE funkwhale TO funkwhale;
Assuming you already have :ref:`created your funkwhale user <create-funkwhale-user>`,
you should now be able to open a postgresql shell:
.. warning::
It's importing that you use utf-8 encoding for your database,
otherwise you'll end up with errors and crashes later on when dealing
with music metedata that contains non-ascii chars.
.. code-block:: shell
sudo -u funkwhale -H psql
@ -49,7 +58,7 @@ for funkwhale to work properly:
.. code-block:: shell
sudo -u postgres psql -c 'CREATE EXTENSION "unaccent";''
sudo -u postgres psql -c 'CREATE EXTENSION "unaccent";'
Cache setup (Redis)

View File

@ -28,10 +28,16 @@ On a dockerized instance with 2 CPUs and a few active users, the memory footprin
funkwhale_postgres_1 22.73 MiB
funkwhale_redis_1 1.496 MiB
Some users have reported running Funkwhale on Raspberry Pis with a memory
consumption of less than 350MiB.
Thus, Funkwhale should run fine on commodity hardware, small hosting boxes and
Raspberry Pi. We lack real-world exemples of such deployments, so don't hesitate
do give us your feedback (either positive or negative).
Check out :doc:`optimization` for advices on how to tune your instance on small
configurations.
Software requirements
---------------------
@ -86,7 +92,7 @@ Files for the web frontend are purely static and can simply be downloaded, unzip
Reverse proxy
--------------
In order to make funkwhale accessible from outside your server and to play nicely with other applications on your machine, you should configure a reverse proxy. At the moment, we only have documentation for nginx, if you know how to implement the same thing for apache, you're welcome.
In order to make funkwhale accessible from outside your server and to play nicely with other applications on your machine, you should configure a reverse proxy.
Nginx
^^^^^
@ -103,9 +109,44 @@ Then, download our sample virtualhost file and proxy conf:
.. parsed-literal::
curl -L -o /etc/nginx/funkwhale_proxy.conf "https://code.eliotberriot.com/funkwhale/funkwhale/raw/|version|/deploy/funkwhale_proxy.conf"
curl -L -o /etc/nginx/sites-enabled/funkwhale.conf "https://code.eliotberriot.com/funkwhale/funkwhale/raw/|version|/deploy/nginx.conf"
curl -L -o /etc/nginx/sites-available/funkwhale.conf "https://code.eliotberriot.com/funkwhale/funkwhale/raw/|version|/deploy/nginx.conf"
ln -s /etc/nginx/sites-available/funkwhale.conf /etc/nginx/sites-enabled/
Ensure static assets and proxy pass match your configuration, and check the configuration is valid with ``nginx -t``. If everything is fine, you can restart your nginx server with ``service nginx restart``.
Ensure static assets and proxy pass match your configuration, and check the configuration is valid with ``nginx -t``.
If everything is fine, you can restart your nginx server with ``service nginx restart``.
Apache2
^^^^^^^
.. note::
Apache2 support is still very recent and the following features
are not working yet:
- Websocket (used for real-time updates on Instance timeline)
- Transcoding of audio files
Those features are not necessary to use your Funkwhale instance, and
transcoding in particular is still in alpha-state anyway.
Ensure you have a recent version of apache2 installed on your server.
You'll also need the following dependencies::
apt install libapache2-mod-xsendfile
Then, download our sample virtualhost file:
.. parsed-literal::
curl -L -o /etc/apache2/sites-available/funkwhale.conf "https://code.eliotberriot.com/funkwhale/funkwhale/raw/|version|/deploy/apache.conf"
ln -s /etc/apache2/sites-available/funkwhale.conf /etc/apache2/sites-enabled/
You can tweak the configuration file according to your setup, especially the
TLS configuration. Otherwise, defaults, should work if you followed the
installation guide.
Check the configuration is valid with ``apache2ctl configtest``, and once you're
done, load the new configuration with ``service apache2 restart``.
About internal locations
~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -0,0 +1,37 @@
Optimizing your Funkwhale instance
==================================
Depending on your requirements, you may want to reduce as much as possible
Funkwhale's footprint.
Reduce workers concurrency
--------------------------
Asynchronous tasks are handled by a celery worker, which will by default
spawn a worker process per CPU available. This can lead to a higher
memory usage.
You can control this behaviour using the ``--concurrency`` flag.
For instance, setting ``--concurrency=1`` will spawn only one worker.
This flag should be appended after the ``celery -A funkwhale_api.taskapp worker``
command in your :file:`docker-compose.yml` file if your using Docker, or in your
:file:`/etc/systemd/system/funkwhale-worker.service` otherwise.
.. note::
Reducing concurrency comes at a cost: asynchronous tasks will be processed
more slowly. However, on small instances, this should not be an issue.
Switch from prefork to solo pool
--------------------------------
Using a different pool implementation for Celery tasks may also help.
Using the ``solo`` pool type should reduce your memory consumption.
You can control this behaviour using the ``--pool=solo`` flag.
This flag should be appended after the ``celery -A funkwhale_api.taskapp worker``
command in your :file:`docker-compose.yml` file if your using Docker, or in your
:file:`/etc/systemd/system/funkwhale-worker.service` otherwise.

186
docs/swagger.yml Normal file
View File

@ -0,0 +1,186 @@
openapi: "3.0"
info:
description: "Documentation for [Funkwhale](https://funkwhale.audio) API. The API is **not** stable yet."
version: "1.0.0"
title: "Funkwhale API"
servers:
- url: https://demo.funkwhale.audio/api/v1
description: Demo server
- url: https://node1.funkwhale.test/api/v1
description: Node 1 (local)
components:
securitySchemes:
jwt:
type: http
scheme: bearer
bearerFormat: JWT
description: "You can get a token by using the /token endpoint"
security:
- jwt: []
paths:
/token/:
post:
tags:
- "auth"
description:
Obtain a JWT token you can use for authenticating your next requests.
security: []
responses:
'200':
description: Successfull auth
'400':
description: Invalid credentials
requestBody:
required: true
content:
application/json:
schema:
type: "object"
properties:
username:
type: "string"
example: "demo"
password:
type: "string"
example: "demo"
/artists/:
get:
tags:
- "artists"
parameters:
- name: "q"
in: "query"
description: "Search query used to filter artists"
schema:
required: false
type: "string"
example: "carpenter"
- name: "listenable"
in: "query"
description: "Filter/exclude artists with listenable tracks"
schema:
required: false
type: "boolean"
responses:
200:
content:
application/json:
schema:
type: "object"
properties:
count:
$ref: "#/properties/resultsCount"
results:
type: "array"
items:
$ref: "#/definitions/ArtistNested"
properties:
resultsCount:
type: "integer"
format: "int64"
description: "The total number of resources matching the request"
mbid:
type: "string"
formats: "uuid"
description: "A musicbrainz ID"
definitions:
Artist:
type: "object"
properties:
mbid:
required: false
$ref: "#/properties/mbid"
id:
type: "integer"
format: "int64"
example: 42
name:
type: "string"
example: "System of a Down"
creation_date:
type: "string"
format: "date-time"
ArtistNested:
type: "object"
allOf:
- $ref: "#/definitions/Artist"
- type: "object"
properties:
albums:
type: "array"
items:
$ref: "#/definitions/AlbumNested"
Album:
type: "object"
properties:
mbid:
required: false
$ref: "#/properties/mbid"
id:
type: "integer"
format: "int64"
example: 16
artist:
type: "integer"
format: "int64"
example: 42
title:
type: "string"
example: "Toxicity"
creation_date:
type: "string"
format: "date-time"
release_date:
type: "string"
required: false
format: "date"
example: "2001-01-01"
AlbumNested:
type: "object"
allOf:
- $ref: "#/definitions/Album"
- type: "object"
properties:
tracks:
type: "array"
items:
$ref: "#/definitions/Track"
Track:
type: "object"
properties:
mbid:
required: false
$ref: "#/properties/mbid"
id:
type: "integer"
format: "int64"
example: 66
artist:
type: "integer"
format: "int64"
example: 42
album:
type: "integer"
format: "int64"
example: 16
title:
type: "string"
example: "Chop Suey!"
position:
required: false
description: "Position of the track in the album"
type: "number"
minimum: 1
example: 1
creation_date:
type: "string"
format: "date-time"

17
docs/third-party.rst Normal file
View File

@ -0,0 +1,17 @@
Third party projects
====================
This page lists all known projects that are maintained by third-parties
and integrate or relates to Funkwhale.
.. note::
If you want your project to be added or removed from this page,
please open an issue on our issue tracker.
API Clients
-----------
- `libfunkwhale <https://github.com/BaptisteGelez/libfunkwhale>`_: a Funkwhale API written in Vala
- `Funkwhale-javalib <https://github.com/PhieF/FunkWhale-javalib>`_: a Funkwhale API client written in Java

View File

@ -97,6 +97,12 @@ html, body {
}
}
.ellipsis {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.ui.small.text.container {
max-width: 500px !important;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -30,24 +30,56 @@
</div>
<div class="tabs">
<div class="ui bottom attached active tab" data-tab="library">
<div class="ui inverted vertical fluid menu">
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'profile', params: {username: $store.state.auth.username}}"><i class="user icon"></i>{{ $t('Logged in as {%name%}', { name: $store.state.auth.username }) }}</router-link>
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i> {{ $t('Logout') }}</router-link>
<router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> {{ $t('Login') }}</router-link>
<router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>{{ $t('Browse library') }}</router-link>
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{path: '/favorites'}"><i class="heart icon"></i> {{ $t('Favorites') }}</router-link>
<a
@click="$store.commit('playlists/chooseTrack', null)"
v-if="$store.state.auth.authenticated"
class="item">
<i class="list icon"></i> {{ $t('Playlists') }}
</a>
<router-link
v-if="$store.state.auth.authenticated"
class="item" :to="{path: '/activity'}"><i class="bell icon"></i> {{ $t('Activity') }}</router-link>
<router-link
class="item" v-if="$store.state.auth.availablePermissions['federation.manage']"
:to="{path: '/manage/federation/libraries'}"><i class="sitemap icon"></i> {{ $t('Federation') }}</router-link>
<div class="ui inverted vertical large fluid menu">
<div class="item">
<div class="header">{{ $t('My account') }}</div>
<div class="menu">
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'profile', params: {username: $store.state.auth.username}}"><i class="user icon"></i>{{ $t('Logged in as {%name%}', { name: $store.state.auth.username }) }}</router-link>
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i>{{ $t('Logout') }}</router-link>
<router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i>{{ $t('Login') }}</router-link>
</div>
</div>
<div class="item">
<div class="header">{{ $t('Music') }}</div>
<div class="menu">
<router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>{{ $t('Browse library') }}</router-link>
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{path: '/favorites'}"><i class="heart icon"></i>{{ $t('Favorites') }}</router-link>
<a
@click="$store.commit('playlists/chooseTrack', null)"
v-if="$store.state.auth.authenticated"
class="item">
<i class="list icon"></i>{{ $t('Playlists') }}
</a>
<router-link
v-if="$store.state.auth.authenticated"
class="item" :to="{path: '/activity'}"><i class="bell icon"></i>{{ $t('Activity') }}</router-link>
</div>
</div>
<div class="item" v-if="showAdmin">
<div class="header">{{ $t('Administration') }}</div>
<div class="menu">
<router-link
class="item"
v-if="$store.state.auth.availablePermissions['import.launch']"
:to="{name: 'library.requests', query: {status: 'pending' }}">
<i class="download icon"></i>{{ $t('Import requests') }}
<div
:class="['ui', {'teal': notifications.importRequests > 0}, 'label']"
:title="$t('Pending import requests')">
{{ notifications.importRequests }}</div>
</router-link>
<router-link
class="item"
v-if="$store.state.auth.availablePermissions['federation.manage']"
:to="{path: '/manage/federation/libraries'}">
<i class="sitemap icon"></i>{{ $t('Federation') }}
<div
:class="['ui', {'teal': notifications.federation > 0}, 'label']"
:title="$t('Pending follow requests')">
{{ notifications.federation }}</div>
</router-link>
</div>
</div>
</div>
</div>
<div v-if="queue.previousQueue " class="ui black icon message">
@ -104,6 +136,7 @@
<script>
import {mapState, mapActions} from 'vuex'
import axios from 'axios'
import Player from '@/components/audio/Player'
import Logo from '@/components/Logo'
@ -125,22 +158,69 @@ export default {
return {
selectedTab: 'library',
backend: backend,
isCollapsed: true
isCollapsed: true,
fetchInterval: null,
notifications: {
federation: 0,
importRequests: 0
}
}
},
mounted () {
$(this.$el).find('.menu .item').tab()
},
created () {
this.fetchNotificationsCount()
this.fetchInterval = setInterval(
this.fetchNotificationsCount, 1000 * 60 * 15)
},
destroy () {
if (this.fetchInterval) {
clearInterval(this.fetchInterval)
}
},
computed: {
...mapState({
queue: state => state.queue,
url: state => state.route.path
})
}),
showAdmin () {
let adminPermissions = [
this.$store.state.auth.availablePermissions['federation.manage'],
this.$store.state.auth.availablePermissions['import.launch']
]
return adminPermissions.filter(e => {
return e
}).length > 0
}
},
methods: {
...mapActions({
cleanTrack: 'queue/cleanTrack'
}),
fetchNotificationsCount () {
this.fetchFederationNotificationsCount()
this.fetchFederationImportRequestsCount()
},
fetchFederationNotificationsCount () {
if (!this.$store.state.auth.availablePermissions['federation.manage']) {
return
}
let self = this
axios.get('federation/libraries/followers/', {params: {pending: true}}).then(response => {
self.notifications.federation = response.data.count
})
},
fetchFederationImportRequestsCount () {
if (!this.$store.state.auth.availablePermissions['import.launch']) {
return
}
let self = this
axios.get('requests/import-requests/', {params: {status: 'pending'}}).then(response => {
console.log('YOLo')
self.notifications.importRequests = response.data.count
})
},
reorder: function (event) {
this.$store.commit('queue/reorder', {
oldIndex: event.oldIndex, newIndex: event.newIndex})
@ -173,6 +253,13 @@ export default {
if (this.selectedTab !== 'queue') {
this.scrollToCurrent()
}
},
'$store.state.availablePermissions': {
handler () {
console.log('YOLO')
this.fetchNotificationsCount()
},
deep: true
}
}
}
@ -182,7 +269,7 @@ export default {
<style scoped lang="scss">
@import '../style/vendor/media';
$sidebar-color: #3D3E3F;
$sidebar-color: #3d3e3f;
.sidebar {
background: $sidebar-color;
@ -231,6 +318,18 @@ $sidebar-color: #3D3E3F;
}
}
}
.vertical.menu {
.item .item {
font-size: 1em;
> i.icon {
float: none;
margin: 0 0.5em 0 0;
}
&:not(.active) {
color: rgba(255, 255, 255, 0.75);
}
}
}
.tabs {
flex: 1;
display: flex;

View File

@ -12,9 +12,15 @@
</ul>
</div>
<div class="field">
<i18next tag="label" path="Username or email"/>
<label>
{{ $t('Username or email') }} |
<router-link :to="{path: '/signup'}">
{{ $t('Create an account') }}
</router-link>
</label>
<input
ref="username"
tabindex="1"
required
type="text"
autofocus
@ -23,18 +29,16 @@
>
</div>
<div class="field">
<i18next tag="label" path="Password"/>
<input
required
type="password"
placeholder="Enter your password"
v-model="credentials.password"
>
<label>
{{ $t('Password') }} |
<router-link :to="{name: 'auth.password-reset', query: {email: credentials.username}}">
{{ $t('Reset your password') }}
</router-link>
</label>
<password-input :index="2" required v-model="credentials.password" />
</div>
<button :class="['ui', {'loading': isLoading}, 'button']" type="submit"><i18next path="Login"/></button>
<router-link class="ui right floated basic button" :to="{path: '/signup'}">
<i18next path="Create an account"/>
</router-link>
<button tabindex="3" :class="['ui', {'loading': isLoading}, 'right', 'floated', 'green', 'button']" type="submit"><i18next path="Login"/></button>
</form>
</div>
</div>
@ -42,12 +46,15 @@
</template>
<script>
import PasswordInput from '@/components/forms/PasswordInput'
export default {
name: 'login',
props: {
next: {type: String, default: '/'}
},
components: {
PasswordInput
},
data () {
return {
// We need to initialize the component with any

View File

@ -35,21 +35,13 @@
</div>
<div class="field">
<label><i18next path="Old password"/></label>
<input
required
type="password"
autofocus
placeholder="Enter your old password"
v-model="old_password">
<password-input required v-model="old_password" />
</div>
<div class="field">
<label><i18next path="New password"/></label>
<input
required
type="password"
autofocus
placeholder="Enter your new password"
v-model="new_password">
<password-input required v-model="new_password" />
</div>
<button :class="['ui', {'loading': isLoading}, 'button']" type="submit"><i18next path="Change password"/></button>
</form>
@ -62,8 +54,12 @@
import $ from 'jquery'
import axios from 'axios'
import logger from '@/logging'
import PasswordInput from '@/components/forms/PasswordInput'
export default {
components: {
PasswordInput
},
data () {
let d = {
// We need to initialize the component with any

View File

@ -34,16 +34,7 @@
</div>
<div class="field">
<i18next tag="label" path="Password"/>
<div class="ui action input">
<input
required
:type="passwordInputType"
placeholder="Enter your password"
v-model="password">
<span @click="showPassword = !showPassword" title="Show/hide password" class="ui icon button">
<i class="eye icon"></i>
</span>
</div>
<password-input v-model="password" />
</div>
<button :class="['ui', 'green', {'loading': isLoading}, 'button']" type="submit"><i18next path="Create my account"/></button>
</form>
@ -57,8 +48,13 @@
import axios from 'axios'
import logger from '@/logging'
import PasswordInput from '@/components/forms/PasswordInput'
export default {
name: 'login',
components: {
PasswordInput
},
props: {
next: {type: String, default: '/'}
},
@ -69,8 +65,7 @@ export default {
password: '',
isLoadingInstanceSetting: true,
errors: [],
isLoading: false,
showPassword: false
isLoading: false
}
},
created () {
@ -104,16 +99,7 @@ export default {
self.isLoading = false
})
}
},
computed: {
passwordInputType () {
if (this.showPassword) {
return 'text'
}
return 'password'
}
}
}
</script>

View File

@ -1,8 +1,14 @@
<template>
<div class="ui card">
<div class="content">
<div class="header">
{{ displayName }}
<div class="header ellipsis">
<router-link
v-if="library"
:title="displayName"
:to="{name: 'federation.libraries.detail', params: {id: library.uuid }}">
{{ displayName }}
</router-link>
<span :title="displayName" v-else>{{ displayName }}</span>
</div>
</div>
<div class="content">

View File

@ -72,7 +72,7 @@ export default {
this.isLoading = true
self.errors = []
self.result = null
axios.get('/federation/libraries/fetch/', {params: {account: this.libraryUsername}}).then((response) => {
axios.get('/federation/libraries/fetch/', {params: {account: this.libraryUsername.trim()}}).then((response) => {
self.result = response.data
self.result.display_name = self.libraryUsername
self.isLoading = false

View File

@ -89,7 +89,7 @@
<router-link
v-if="importBatch"
:to="{name: 'library.import.batches.detail', params: {id: importBatch.id }}">
<i18next path="Import #{%id%} launched" :id="importBatch.id"/>
{{ $t('Import #{% id %} launched', {id: importBatch.id}) }}
</router-link>
</th>
<th></th>

View File

@ -0,0 +1,31 @@
<template>
<div class="ui action input">
<input
required
:tabindex="index"
:type="passwordInputType"
@input="$emit('input', $event.target.value)"
:value="value">
<span @click="showPassword = !showPassword" :title="$t('Show/hide password')" class="ui icon button">
<i class="eye icon"></i>
</span>
</div>
</template>
<script>
export default {
props: ['value', 'index'],
data () {
return {
showPassword: false
}
},
computed: {
passwordInputType () {
if (this.showPassword) {
return 'text'
}
return 'password'
}
}
}
</script>

Some files were not shown because too many files have changed in this diff Show More