Merge branch 'release/0.14.2'
This commit is contained in:
commit
544a60b800
|
@ -3,13 +3,39 @@ variables:
|
||||||
IMAGE: $IMAGE_NAME:$CI_COMMIT_REF_NAME
|
IMAGE: $IMAGE_NAME:$CI_COMMIT_REF_NAME
|
||||||
IMAGE_LATEST: $IMAGE_NAME:latest
|
IMAGE_LATEST: $IMAGE_NAME:latest
|
||||||
PIP_CACHE_DIR: "$CI_PROJECT_DIR/pip-cache"
|
PIP_CACHE_DIR: "$CI_PROJECT_DIR/pip-cache"
|
||||||
|
PYTHONDONTWRITEBYTECODE: "true"
|
||||||
|
|
||||||
|
|
||||||
stages:
|
stages:
|
||||||
|
- lint
|
||||||
- test
|
- test
|
||||||
- build
|
- build
|
||||||
- deploy
|
- deploy
|
||||||
|
|
||||||
|
black:
|
||||||
|
image: python:3.6
|
||||||
|
stage: lint
|
||||||
|
variables:
|
||||||
|
GIT_STRATEGY: fetch
|
||||||
|
before_script:
|
||||||
|
- pip install black
|
||||||
|
script:
|
||||||
|
- black --check --diff api/
|
||||||
|
|
||||||
|
flake8:
|
||||||
|
image: python:3.6
|
||||||
|
stage: lint
|
||||||
|
variables:
|
||||||
|
GIT_STRATEGY: fetch
|
||||||
|
before_script:
|
||||||
|
- pip install flake8
|
||||||
|
script:
|
||||||
|
- flake8 -v api
|
||||||
|
cache:
|
||||||
|
key: "$CI_PROJECT_ID__flake8_pip_cache"
|
||||||
|
paths:
|
||||||
|
- "$PIP_CACHE_DIR"
|
||||||
|
|
||||||
test_api:
|
test_api:
|
||||||
services:
|
services:
|
||||||
- postgres:9.4
|
- postgres:9.4
|
||||||
|
@ -108,7 +134,7 @@ pages:
|
||||||
tags:
|
tags:
|
||||||
- docker
|
- docker
|
||||||
|
|
||||||
docker_develop:
|
docker_release:
|
||||||
stage: deploy
|
stage: deploy
|
||||||
before_script:
|
before_script:
|
||||||
- docker login -u $DOCKER_LOGIN -p $DOCKER_PASSWORD
|
- docker login -u $DOCKER_LOGIN -p $DOCKER_PASSWORD
|
||||||
|
@ -119,8 +145,9 @@ docker_develop:
|
||||||
- docker push $IMAGE
|
- docker push $IMAGE
|
||||||
only:
|
only:
|
||||||
- develop@funkwhale/funkwhale
|
- develop@funkwhale/funkwhale
|
||||||
|
- tags@funkwhale/funkwhale
|
||||||
tags:
|
tags:
|
||||||
- dind
|
- docker-build
|
||||||
|
|
||||||
build_api:
|
build_api:
|
||||||
# Simply publish a zip containing api/ directory
|
# Simply publish a zip containing api/ directory
|
||||||
|
@ -135,19 +162,3 @@ build_api:
|
||||||
- tags@funkwhale/funkwhale
|
- tags@funkwhale/funkwhale
|
||||||
- master@funkwhale/funkwhale
|
- master@funkwhale/funkwhale
|
||||||
- develop@funkwhale/funkwhale
|
- develop@funkwhale/funkwhale
|
||||||
|
|
||||||
|
|
||||||
docker_release:
|
|
||||||
stage: deploy
|
|
||||||
before_script:
|
|
||||||
- docker login -u $DOCKER_LOGIN -p $DOCKER_PASSWORD
|
|
||||||
- cp -r front/dist api/frontend
|
|
||||||
- cd api
|
|
||||||
script:
|
|
||||||
- docker build -t $IMAGE -t $IMAGE_LATEST .
|
|
||||||
- docker push $IMAGE
|
|
||||||
- docker push $IMAGE_LATEST
|
|
||||||
only:
|
|
||||||
- tags@funkwhale/funkwhale
|
|
||||||
tags:
|
|
||||||
- dind
|
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
<!--
|
||||||
|
Hi there! You are reporting a bug on this project, and we want to thank you!
|
||||||
|
|
||||||
|
To ensure your bug report is as useful as possible, please try to stick
|
||||||
|
to the following structure. You can leave the parts text between `<!- ->`
|
||||||
|
markers untouched, they won't be displayed in your final message.
|
||||||
|
|
||||||
|
Please do not edit the following line, it's used for automatic classification
|
||||||
|
-->
|
||||||
|
|
||||||
|
/label ~"Type: Bug" ~"Status: Need triage"
|
||||||
|
|
||||||
|
## Steps to reproduce
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Describe the steps to reproduce the issue, like:
|
||||||
|
|
||||||
|
1. Visit the page at /artists/
|
||||||
|
2. Type that
|
||||||
|
3. Submit
|
||||||
|
-->
|
||||||
|
|
||||||
|
## What happens?
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Describe what happens once the previous steps are completed.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## What is expected?
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Describe the expected behaviour.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
<!--
|
||||||
|
If relevant, share additional context here like:
|
||||||
|
|
||||||
|
- Browser type and version (for front-end bugs)
|
||||||
|
- Instance configuration (Docker/non-docker, nginx/apache as proxy, etc.)
|
||||||
|
- Error messages, screenshots and logs
|
||||||
|
-->
|
|
@ -0,0 +1,39 @@
|
||||||
|
<!--
|
||||||
|
Hi there! You are about to share feature request or an idea, and we want to thank you!
|
||||||
|
|
||||||
|
To ensure we can deal with your idea or request, please try to stick
|
||||||
|
to the following structure. You can leave the parts text between `<!- ->`
|
||||||
|
markers untouched, they won't be displayed in your final message.
|
||||||
|
|
||||||
|
Please do not edit the following line, it's used for automatic classification
|
||||||
|
-->
|
||||||
|
|
||||||
|
/label ~"Type: New feature" ~"Status: Need triage"
|
||||||
|
|
||||||
|
## What is the problem you are facing?
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Describe the problem you'd like to solve, and why we need to add or
|
||||||
|
improve something in the current system to solve that problem.
|
||||||
|
|
||||||
|
Be as specific as possible.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## What are the possible drawbacks or issues with the requested changes?
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Altering the system behaviour is not always a free action, and it can impact
|
||||||
|
user experience, performance, introduce bugs or complexity, etc..
|
||||||
|
|
||||||
|
If you think about anything we should keep in mind while
|
||||||
|
examining your request, please describe it in this section.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
<!--
|
||||||
|
If relevant, share additional context here like:
|
||||||
|
|
||||||
|
- Links to existing implementations or examples of the requested feature
|
||||||
|
- Screenshots
|
||||||
|
-->
|
127
CHANGELOG
127
CHANGELOG
|
@ -10,6 +10,133 @@ This changelog is viewable on the web at https://docs.funkwhale.audio/changelog.
|
||||||
|
|
||||||
.. towncrier
|
.. towncrier
|
||||||
|
|
||||||
|
0.14.2 (2018-06-16)
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
|
||||||
|
This release contains a fix for a permission issue. You should upgrade
|
||||||
|
as soon as possible. Read the changelog below for more details.
|
||||||
|
|
||||||
|
Upgrade instructions are available at
|
||||||
|
https://docs.funkwhale.audio/upgrading.html
|
||||||
|
|
||||||
|
Enhancements:
|
||||||
|
|
||||||
|
- Added feedback on shuffle button (#262)
|
||||||
|
- Added multiple warnings in the documentation that you should never run
|
||||||
|
makemigrations yourself (#291)
|
||||||
|
- Album cover served in http (#264)
|
||||||
|
- Apache2 reverse proxy now supports websockets (tested with Apache 2.4.25)
|
||||||
|
(!252)
|
||||||
|
- Display file size in human format during file upload (#289)
|
||||||
|
- Switch from BSD-3 licence to AGPL-3 licence (#280)
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
|
||||||
|
- Ensure radios can only be edited and deleted by their owners (#311)
|
||||||
|
- Fixed admin menu not showing after login (#245)
|
||||||
|
- Fixed broken pagination in Subsonic API (#295)
|
||||||
|
- Fixed duplicated websocket connexion on timeline (#287)
|
||||||
|
|
||||||
|
|
||||||
|
Documentation:
|
||||||
|
|
||||||
|
- Improved documentation about in-place imports setup (#298)
|
||||||
|
|
||||||
|
|
||||||
|
Other:
|
||||||
|
|
||||||
|
- Added Black and flake8 checks in CI to ensure consistent code styling and
|
||||||
|
formatting (#297)
|
||||||
|
- Added bug and feature issue templates (#299)
|
||||||
|
|
||||||
|
|
||||||
|
Permission issues on radios
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
Because of an error in the way we checked user permissions on radios,
|
||||||
|
public radios could be deleted by any logged-in user, even if they were not
|
||||||
|
the owner of the radio.
|
||||||
|
|
||||||
|
We recommend instances owners to upgrade as fast as possible to avoid any abuse
|
||||||
|
and data loss.
|
||||||
|
|
||||||
|
|
||||||
|
Funkwhale is now licenced under AGPL-3
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
Following the recent switch made by PixelFed
|
||||||
|
(https://github.com/dansup/pixelfed/issues/143), we decided along with
|
||||||
|
the community to relicence Funkwhale under the AGPL-3 licence. We did this
|
||||||
|
switch for various reasons:
|
||||||
|
|
||||||
|
- This is better aligned with other fediverse software
|
||||||
|
- It prohibits anyone to distribute closed-source and proprietary forks of Funkwhale
|
||||||
|
|
||||||
|
As end users and instance owners, this does not change anything. You can
|
||||||
|
continue to use Funkwhale exactly as you did before :)
|
||||||
|
|
||||||
|
|
||||||
|
Apache support for websocket
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
Up until now, our Apache2 configuration was not working with websockets. This is now
|
||||||
|
solved by adding this at the beginning of your Apache2 configuration file::
|
||||||
|
|
||||||
|
Define funkwhale-api-ws ws://localhost:5000
|
||||||
|
|
||||||
|
And this, before the "/api" block::
|
||||||
|
|
||||||
|
# Activating WebSockets
|
||||||
|
ProxyPass "/api/v1/instance/activity" ${funkwhale-api-ws}/api/v1/instance/activity
|
||||||
|
|
||||||
|
Websockets may not be supported in older versions of Apache2. Be sure to upgrade to the latest version available.
|
||||||
|
|
||||||
|
|
||||||
|
Serving album covers in https (Apache2 proxy)
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
Two issues are addressed here. The first one was about Django replying with
|
||||||
|
mixed content (http) when queried for covers. Setting up the `X-Forwarded-Proto`
|
||||||
|
allows Django to know that the client is using https, and that the reply must
|
||||||
|
be https as well.
|
||||||
|
|
||||||
|
Second issue was a problem of permission causing Apache a denied access to
|
||||||
|
album cover folder. It is solved by adding another block for this path in
|
||||||
|
the Apache configuration file for funkwhale.
|
||||||
|
|
||||||
|
Here is how to modify your `funkwhale.conf` apache2 configuration::
|
||||||
|
|
||||||
|
<VirtualHost *:443>
|
||||||
|
|
||||||
|
...
|
||||||
|
#Add this new line
|
||||||
|
RequestHeader set X-Forwarded-Proto "https"
|
||||||
|
...
|
||||||
|
# Add this new block below the other <Directory/> blocks
|
||||||
|
# replace /srv/funkwhale/data/media with the path to your media directory
|
||||||
|
# if you're not using the standard layout.
|
||||||
|
<Directory /srv/funkwhale/data/media/albums>
|
||||||
|
Options FollowSymLinks
|
||||||
|
AllowOverride None
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
...
|
||||||
|
</VirtualHost>
|
||||||
|
|
||||||
|
|
||||||
|
About the makemigrations warning
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
You may sometimes get the following warning while applying migrations::
|
||||||
|
|
||||||
|
"Your models have changes that are not yet reflected in a migration, and so won't be applied."
|
||||||
|
|
||||||
|
This is a warning, not an error, and it can be safely ignored.
|
||||||
|
Never run the ``makemigrations`` command yourself.
|
||||||
|
|
||||||
|
|
||||||
0.14.1 (2018-06-06)
|
0.14.1 (2018-06-06)
|
||||||
-------------------
|
-------------------
|
||||||
|
|
||||||
|
|
37
CONTRIBUTING
37
CONTRIBUTING
|
@ -61,16 +61,6 @@ If you do not want to add the ``-f dev.yml`` snippet everytime, you can run this
|
||||||
export COMPOSE_FILE=dev.yml
|
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
|
Creating your env file
|
||||||
^^^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
@ -84,6 +74,24 @@ Create it like this::
|
||||||
touch .env
|
touch .env
|
||||||
|
|
||||||
|
|
||||||
|
Create docker network
|
||||||
|
^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
Create the federation network::
|
||||||
|
|
||||||
|
docker network create federation
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
Database management
|
Database management
|
||||||
^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
@ -124,7 +132,7 @@ Launch all services
|
||||||
|
|
||||||
Then you can run everything with::
|
Then you can run everything with::
|
||||||
|
|
||||||
docker-compose -f dev.yml up
|
docker-compose -f dev.yml up front api nginx celeryworker
|
||||||
|
|
||||||
This will launch all services, and output the logs in your current terminal window.
|
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``.
|
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``.
|
||||||
|
@ -194,13 +202,6 @@ Run a reverse proxy for your instances
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
|
||||||
Create docker network
|
|
||||||
^^^^^^^^^^^^^^^^^^^^
|
|
||||||
|
|
||||||
Create the federation network::
|
|
||||||
|
|
||||||
docker network create federation
|
|
||||||
|
|
||||||
Launch everything
|
Launch everything
|
||||||
^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
|
678
LICENSE
678
LICENSE
|
@ -1,27 +1,661 @@
|
||||||
Copyright (c) 2015, Eliot Berriot
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
All rights reserved.
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without modification,
|
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||||
are permitted provided that the following conditions are met:
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
* Redistributions of source code must retain the above copyright notice, this
|
Preamble
|
||||||
list of conditions and the following disclaimer.
|
|
||||||
|
|
||||||
* Redistributions in binary form must reproduce the above copyright notice, this
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
list of conditions and the following disclaimer in the documentation and/or
|
software and other kinds of works, specifically designed to ensure
|
||||||
other materials provided with the distribution.
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
* Neither the name of funkwhale_api nor the names of its
|
The licenses for most software and other practical works are designed
|
||||||
contributors may be used to endorse or promote products derived from this
|
to take away your freedom to share and change the works. By contrast,
|
||||||
software without specific prior written permission.
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
When we speak of free software, we are referring to freedom, not
|
||||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
have the freedom to distribute copies of free software (and charge for
|
||||||
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
|
them if you wish), that you receive source code or can get it if you
|
||||||
INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
want it, that you can change the software or use pieces of it in new
|
||||||
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
free programs, and that you know you can do these things.
|
||||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
|
|
||||||
OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
|
Developers that use our General Public Licenses protect your rights
|
||||||
OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
OF THE POSSIBILITY OF SUCH DAMAGE.
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<http://www.gnu.org/licenses/>.
|
||||||
|
|
|
@ -7,7 +7,7 @@ Funkwhale
|
||||||
|
|
||||||
A self-hosted tribute to Grooveshark.com.
|
A self-hosted tribute to Grooveshark.com.
|
||||||
|
|
||||||
LICENSE: BSD
|
LICENSE: AGPL3
|
||||||
|
|
||||||
Getting help
|
Getting help
|
||||||
------------
|
------------
|
||||||
|
|
|
@ -1,81 +1,79 @@
|
||||||
|
from django.conf.urls import include, url
|
||||||
|
from dynamic_preferences.api.viewsets import GlobalPreferencesViewSet
|
||||||
from rest_framework import routers
|
from rest_framework import routers
|
||||||
from rest_framework.urlpatterns import format_suffix_patterns
|
from rest_framework.urlpatterns import format_suffix_patterns
|
||||||
from django.conf.urls import include, url
|
from rest_framework_jwt import views as jwt_views
|
||||||
|
|
||||||
from funkwhale_api.activity import views as activity_views
|
from funkwhale_api.activity import views as activity_views
|
||||||
from funkwhale_api.instance import views as instance_views
|
|
||||||
from funkwhale_api.music import views
|
from funkwhale_api.music import views
|
||||||
from funkwhale_api.playlists import views as playlists_views
|
from funkwhale_api.playlists import views as playlists_views
|
||||||
from funkwhale_api.subsonic.views import SubsonicViewSet
|
from funkwhale_api.subsonic.views import SubsonicViewSet
|
||||||
from rest_framework_jwt import views as jwt_views
|
|
||||||
|
|
||||||
from dynamic_preferences.api.viewsets import GlobalPreferencesViewSet
|
|
||||||
from dynamic_preferences.users.viewsets import UserPreferencesViewSet
|
|
||||||
|
|
||||||
router = routers.SimpleRouter()
|
router = routers.SimpleRouter()
|
||||||
router.register(r'settings', GlobalPreferencesViewSet, base_name='settings')
|
router.register(r"settings", GlobalPreferencesViewSet, base_name="settings")
|
||||||
router.register(r'activity', activity_views.ActivityViewSet, 'activity')
|
router.register(r"activity", activity_views.ActivityViewSet, "activity")
|
||||||
router.register(r'tags', views.TagViewSet, 'tags')
|
router.register(r"tags", views.TagViewSet, "tags")
|
||||||
router.register(r'tracks', views.TrackViewSet, 'tracks')
|
router.register(r"tracks", views.TrackViewSet, "tracks")
|
||||||
router.register(r'trackfiles', views.TrackFileViewSet, 'trackfiles')
|
router.register(r"trackfiles", views.TrackFileViewSet, "trackfiles")
|
||||||
router.register(r'artists', views.ArtistViewSet, 'artists')
|
router.register(r"artists", views.ArtistViewSet, "artists")
|
||||||
router.register(r'albums', views.AlbumViewSet, 'albums')
|
router.register(r"albums", views.AlbumViewSet, "albums")
|
||||||
router.register(r'import-batches', views.ImportBatchViewSet, 'import-batches')
|
router.register(r"import-batches", views.ImportBatchViewSet, "import-batches")
|
||||||
router.register(r'import-jobs', views.ImportJobViewSet, 'import-jobs')
|
router.register(r"import-jobs", views.ImportJobViewSet, "import-jobs")
|
||||||
router.register(r'submit', views.SubmitViewSet, 'submit')
|
router.register(r"submit", views.SubmitViewSet, "submit")
|
||||||
router.register(r'playlists', playlists_views.PlaylistViewSet, 'playlists')
|
router.register(r"playlists", playlists_views.PlaylistViewSet, "playlists")
|
||||||
router.register(
|
router.register(
|
||||||
r'playlist-tracks',
|
r"playlist-tracks", playlists_views.PlaylistTrackViewSet, "playlist-tracks"
|
||||||
playlists_views.PlaylistTrackViewSet,
|
)
|
||||||
'playlist-tracks')
|
|
||||||
v1_patterns = router.urls
|
v1_patterns = router.urls
|
||||||
|
|
||||||
subsonic_router = routers.SimpleRouter(trailing_slash=False)
|
subsonic_router = routers.SimpleRouter(trailing_slash=False)
|
||||||
subsonic_router.register(r'subsonic/rest', SubsonicViewSet, base_name='subsonic')
|
subsonic_router.register(r"subsonic/rest", SubsonicViewSet, base_name="subsonic")
|
||||||
|
|
||||||
|
|
||||||
v1_patterns += [
|
v1_patterns += [
|
||||||
url(r'^instance/',
|
url(
|
||||||
|
r"^instance/",
|
||||||
|
include(("funkwhale_api.instance.urls", "instance"), namespace="instance"),
|
||||||
|
),
|
||||||
|
url(
|
||||||
|
r"^manage/",
|
||||||
|
include(("funkwhale_api.manage.urls", "manage"), namespace="manage"),
|
||||||
|
),
|
||||||
|
url(
|
||||||
|
r"^federation/",
|
||||||
include(
|
include(
|
||||||
('funkwhale_api.instance.urls', 'instance'),
|
("funkwhale_api.federation.api_urls", "federation"), namespace="federation"
|
||||||
namespace='instance')),
|
),
|
||||||
url(r'^manage/',
|
),
|
||||||
include(
|
url(
|
||||||
('funkwhale_api.manage.urls', 'manage'),
|
r"^providers/",
|
||||||
namespace='manage')),
|
include(("funkwhale_api.providers.urls", "providers"), namespace="providers"),
|
||||||
url(r'^federation/',
|
),
|
||||||
include(
|
url(
|
||||||
('funkwhale_api.federation.api_urls', 'federation'),
|
r"^favorites/",
|
||||||
namespace='federation')),
|
include(("funkwhale_api.favorites.urls", "favorites"), namespace="favorites"),
|
||||||
url(r'^providers/',
|
),
|
||||||
include(
|
url(r"^search$", views.Search.as_view(), name="search"),
|
||||||
('funkwhale_api.providers.urls', 'providers'),
|
url(
|
||||||
namespace='providers')),
|
r"^radios/",
|
||||||
url(r'^favorites/',
|
include(("funkwhale_api.radios.urls", "radios"), namespace="radios"),
|
||||||
include(
|
),
|
||||||
('funkwhale_api.favorites.urls', 'favorites'),
|
url(
|
||||||
namespace='favorites')),
|
r"^history/",
|
||||||
url(r'^search$',
|
include(("funkwhale_api.history.urls", "history"), namespace="history"),
|
||||||
views.Search.as_view(), name='search'),
|
),
|
||||||
url(r'^radios/',
|
url(
|
||||||
include(
|
r"^users/",
|
||||||
('funkwhale_api.radios.urls', 'radios'),
|
include(("funkwhale_api.users.api_urls", "users"), namespace="users"),
|
||||||
namespace='radios')),
|
),
|
||||||
url(r'^history/',
|
url(
|
||||||
include(
|
r"^requests/",
|
||||||
('funkwhale_api.history.urls', 'history'),
|
include(("funkwhale_api.requests.api_urls", "requests"), namespace="requests"),
|
||||||
namespace='history')),
|
),
|
||||||
url(r'^users/',
|
url(r"^token/$", jwt_views.obtain_jwt_token, name="token"),
|
||||||
include(
|
url(r"^token/refresh/$", jwt_views.refresh_jwt_token, name="token_refresh"),
|
||||||
('funkwhale_api.users.api_urls', 'users'),
|
|
||||||
namespace='users')),
|
|
||||||
url(r'^requests/',
|
|
||||||
include(
|
|
||||||
('funkwhale_api.requests.api_urls', 'requests'),
|
|
||||||
namespace='requests')),
|
|
||||||
url(r'^token/$', jwt_views.obtain_jwt_token, name='token'),
|
|
||||||
url(r'^token/refresh/$', jwt_views.refresh_jwt_token, name='token_refresh'),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r'^v1/', include((v1_patterns, 'v1'), namespace='v1'))
|
url(r"^v1/", include((v1_patterns, "v1"), namespace="v1"))
|
||||||
] + format_suffix_patterns(subsonic_router.urls, allowed=['view'])
|
] + format_suffix_patterns(subsonic_router.urls, allowed=["view"])
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import django
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
|
import django
|
||||||
|
|
||||||
django.setup()
|
django.setup()
|
||||||
|
|
||||||
from .routing import application
|
from .routing import application # noqa
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
|
||||||
|
|
|
@ -1,18 +1,16 @@
|
||||||
from django.conf.urls import url
|
|
||||||
|
|
||||||
from channels.auth import AuthMiddlewareStack
|
|
||||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||||
|
from django.conf.urls import url
|
||||||
|
|
||||||
from funkwhale_api.common.auth import TokenAuthMiddleware
|
from funkwhale_api.common.auth import TokenAuthMiddleware
|
||||||
from funkwhale_api.instance import consumers
|
from funkwhale_api.instance import consumers
|
||||||
|
|
||||||
|
application = ProtocolTypeRouter(
|
||||||
application = ProtocolTypeRouter({
|
{
|
||||||
# Empty for now (http->django views is added by default)
|
# Empty for now (http->django views is added by default)
|
||||||
"websocket": TokenAuthMiddleware(
|
"websocket": TokenAuthMiddleware(
|
||||||
URLRouter([
|
URLRouter(
|
||||||
url("^api/v1/instance/activity$",
|
[url("^api/v1/instance/activity$", consumers.InstanceActivityConsumer)]
|
||||||
consumers.InstanceActivityConsumer),
|
)
|
||||||
])
|
)
|
||||||
),
|
}
|
||||||
})
|
)
|
||||||
|
|
|
@ -10,131 +10,125 @@ https://docs.djangoproject.com/en/dev/ref/settings/
|
||||||
"""
|
"""
|
||||||
from __future__ import absolute_import, unicode_literals
|
from __future__ import absolute_import, unicode_literals
|
||||||
|
|
||||||
from urllib.parse import urlsplit
|
import datetime
|
||||||
import os
|
from urllib.parse import urlparse, urlsplit
|
||||||
|
|
||||||
import environ
|
import environ
|
||||||
from celery.schedules import crontab
|
from celery.schedules import crontab
|
||||||
|
|
||||||
from funkwhale_api import __version__
|
from funkwhale_api import __version__
|
||||||
|
|
||||||
ROOT_DIR = environ.Path(__file__) - 3 # (/a/b/myfile.py - 3 = /)
|
ROOT_DIR = environ.Path(__file__) - 3 # (/a/b/myfile.py - 3 = /)
|
||||||
APPS_DIR = ROOT_DIR.path('funkwhale_api')
|
APPS_DIR = ROOT_DIR.path("funkwhale_api")
|
||||||
|
|
||||||
env = environ.Env()
|
env = environ.Env()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
env.read_env(ROOT_DIR.file('.env'))
|
env.read_env(ROOT_DIR.file(".env"))
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
FUNKWHALE_HOSTNAME = None
|
FUNKWHALE_HOSTNAME = None
|
||||||
FUNKWHALE_HOSTNAME_SUFFIX = env('FUNKWHALE_HOSTNAME_SUFFIX', default=None)
|
FUNKWHALE_HOSTNAME_SUFFIX = env("FUNKWHALE_HOSTNAME_SUFFIX", default=None)
|
||||||
FUNKWHALE_HOSTNAME_PREFIX = env('FUNKWHALE_HOSTNAME_PREFIX', default=None)
|
FUNKWHALE_HOSTNAME_PREFIX = env("FUNKWHALE_HOSTNAME_PREFIX", default=None)
|
||||||
if FUNKWHALE_HOSTNAME_PREFIX and FUNKWHALE_HOSTNAME_SUFFIX:
|
if FUNKWHALE_HOSTNAME_PREFIX and FUNKWHALE_HOSTNAME_SUFFIX:
|
||||||
# We're in traefik case, in development
|
# We're in traefik case, in development
|
||||||
FUNKWHALE_HOSTNAME = '{}.{}'.format(
|
FUNKWHALE_HOSTNAME = "{}.{}".format(
|
||||||
FUNKWHALE_HOSTNAME_PREFIX, FUNKWHALE_HOSTNAME_SUFFIX)
|
FUNKWHALE_HOSTNAME_PREFIX, FUNKWHALE_HOSTNAME_SUFFIX
|
||||||
FUNKWHALE_PROTOCOL = env('FUNKWHALE_PROTOCOL', default='https')
|
)
|
||||||
|
FUNKWHALE_PROTOCOL = env("FUNKWHALE_PROTOCOL", default="https")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
FUNKWHALE_HOSTNAME = env('FUNKWHALE_HOSTNAME')
|
FUNKWHALE_HOSTNAME = env("FUNKWHALE_HOSTNAME")
|
||||||
FUNKWHALE_PROTOCOL = env('FUNKWHALE_PROTOCOL', default='https')
|
FUNKWHALE_PROTOCOL = env("FUNKWHALE_PROTOCOL", default="https")
|
||||||
except Exception:
|
except Exception:
|
||||||
FUNKWHALE_URL = env('FUNKWHALE_URL')
|
FUNKWHALE_URL = env("FUNKWHALE_URL")
|
||||||
_parsed = urlsplit(FUNKWHALE_URL)
|
_parsed = urlsplit(FUNKWHALE_URL)
|
||||||
FUNKWHALE_HOSTNAME = _parsed.netloc
|
FUNKWHALE_HOSTNAME = _parsed.netloc
|
||||||
FUNKWHALE_PROTOCOL = _parsed.scheme
|
FUNKWHALE_PROTOCOL = _parsed.scheme
|
||||||
|
|
||||||
FUNKWHALE_URL = '{}://{}'.format(FUNKWHALE_PROTOCOL, FUNKWHALE_HOSTNAME)
|
FUNKWHALE_URL = "{}://{}".format(FUNKWHALE_PROTOCOL, FUNKWHALE_HOSTNAME)
|
||||||
|
|
||||||
|
|
||||||
# XXX: deprecated, see #186
|
# XXX: deprecated, see #186
|
||||||
FEDERATION_ENABLED = env.bool('FEDERATION_ENABLED', default=True)
|
FEDERATION_ENABLED = env.bool("FEDERATION_ENABLED", default=True)
|
||||||
FEDERATION_HOSTNAME = env('FEDERATION_HOSTNAME', default=FUNKWHALE_HOSTNAME)
|
FEDERATION_HOSTNAME = env("FEDERATION_HOSTNAME", default=FUNKWHALE_HOSTNAME)
|
||||||
# XXX: deprecated, see #186
|
# XXX: deprecated, see #186
|
||||||
FEDERATION_COLLECTION_PAGE_SIZE = env.int(
|
FEDERATION_COLLECTION_PAGE_SIZE = env.int("FEDERATION_COLLECTION_PAGE_SIZE", default=50)
|
||||||
'FEDERATION_COLLECTION_PAGE_SIZE', default=50
|
|
||||||
)
|
|
||||||
# XXX: deprecated, see #186
|
# XXX: deprecated, see #186
|
||||||
FEDERATION_MUSIC_NEEDS_APPROVAL = env.bool(
|
FEDERATION_MUSIC_NEEDS_APPROVAL = env.bool(
|
||||||
'FEDERATION_MUSIC_NEEDS_APPROVAL', default=True
|
"FEDERATION_MUSIC_NEEDS_APPROVAL", default=True
|
||||||
)
|
)
|
||||||
# XXX: deprecated, see #186
|
# XXX: deprecated, see #186
|
||||||
FEDERATION_ACTOR_FETCH_DELAY = env.int(
|
FEDERATION_ACTOR_FETCH_DELAY = env.int("FEDERATION_ACTOR_FETCH_DELAY", default=60 * 12)
|
||||||
'FEDERATION_ACTOR_FETCH_DELAY', default=60 * 12)
|
ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS")
|
||||||
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
|
|
||||||
|
|
||||||
# APP CONFIGURATION
|
# APP CONFIGURATION
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
DJANGO_APPS = (
|
DJANGO_APPS = (
|
||||||
'channels',
|
"channels",
|
||||||
# Default Django apps:
|
# Default Django apps:
|
||||||
'django.contrib.auth',
|
"django.contrib.auth",
|
||||||
'django.contrib.contenttypes',
|
"django.contrib.contenttypes",
|
||||||
'django.contrib.sessions',
|
"django.contrib.sessions",
|
||||||
'django.contrib.sites',
|
"django.contrib.sites",
|
||||||
'django.contrib.messages',
|
"django.contrib.messages",
|
||||||
'django.contrib.staticfiles',
|
"django.contrib.staticfiles",
|
||||||
'django.contrib.postgres',
|
"django.contrib.postgres",
|
||||||
|
|
||||||
# Useful template tags:
|
# Useful template tags:
|
||||||
# 'django.contrib.humanize',
|
# 'django.contrib.humanize',
|
||||||
|
|
||||||
# Admin
|
# Admin
|
||||||
'django.contrib.admin',
|
"django.contrib.admin",
|
||||||
)
|
)
|
||||||
THIRD_PARTY_APPS = (
|
THIRD_PARTY_APPS = (
|
||||||
# 'crispy_forms', # Form layouts
|
# 'crispy_forms', # Form layouts
|
||||||
'allauth', # registration
|
"allauth", # registration
|
||||||
'allauth.account', # registration
|
"allauth.account", # registration
|
||||||
'allauth.socialaccount', # registration
|
"allauth.socialaccount", # registration
|
||||||
'corsheaders',
|
"corsheaders",
|
||||||
'rest_framework',
|
"rest_framework",
|
||||||
'rest_framework.authtoken',
|
"rest_framework.authtoken",
|
||||||
'taggit',
|
"taggit",
|
||||||
'rest_auth',
|
"rest_auth",
|
||||||
'rest_auth.registration',
|
"rest_auth.registration",
|
||||||
'dynamic_preferences',
|
"dynamic_preferences",
|
||||||
'django_filters',
|
"django_filters",
|
||||||
'cacheops',
|
"cacheops",
|
||||||
'django_cleanup',
|
"django_cleanup",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Sentry
|
# Sentry
|
||||||
RAVEN_ENABLED = env.bool("RAVEN_ENABLED", default=False)
|
RAVEN_ENABLED = env.bool("RAVEN_ENABLED", default=False)
|
||||||
RAVEN_DSN = env("RAVEN_DSN", default='')
|
RAVEN_DSN = env("RAVEN_DSN", default="")
|
||||||
|
|
||||||
if RAVEN_ENABLED:
|
if RAVEN_ENABLED:
|
||||||
RAVEN_CONFIG = {
|
RAVEN_CONFIG = {
|
||||||
'dsn': RAVEN_DSN,
|
"dsn": RAVEN_DSN,
|
||||||
# If you are using git, you can also automatically configure the
|
# If you are using git, you can also automatically configure the
|
||||||
# release based on the git info.
|
# release based on the git info.
|
||||||
'release': __version__,
|
"release": __version__,
|
||||||
}
|
}
|
||||||
THIRD_PARTY_APPS += (
|
THIRD_PARTY_APPS += ("raven.contrib.django.raven_compat",)
|
||||||
'raven.contrib.django.raven_compat',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Apps specific for this project go here.
|
# Apps specific for this project go here.
|
||||||
LOCAL_APPS = (
|
LOCAL_APPS = (
|
||||||
'funkwhale_api.common',
|
"funkwhale_api.common",
|
||||||
'funkwhale_api.activity.apps.ActivityConfig',
|
"funkwhale_api.activity.apps.ActivityConfig",
|
||||||
'funkwhale_api.users', # custom users app
|
"funkwhale_api.users", # custom users app
|
||||||
# Your stuff: custom apps go here
|
# Your stuff: custom apps go here
|
||||||
'funkwhale_api.instance',
|
"funkwhale_api.instance",
|
||||||
'funkwhale_api.music',
|
"funkwhale_api.music",
|
||||||
'funkwhale_api.requests',
|
"funkwhale_api.requests",
|
||||||
'funkwhale_api.favorites',
|
"funkwhale_api.favorites",
|
||||||
'funkwhale_api.federation',
|
"funkwhale_api.federation",
|
||||||
'funkwhale_api.radios',
|
"funkwhale_api.radios",
|
||||||
'funkwhale_api.history',
|
"funkwhale_api.history",
|
||||||
'funkwhale_api.playlists',
|
"funkwhale_api.playlists",
|
||||||
'funkwhale_api.providers.audiofile',
|
"funkwhale_api.providers.audiofile",
|
||||||
'funkwhale_api.providers.youtube',
|
"funkwhale_api.providers.youtube",
|
||||||
'funkwhale_api.providers.acoustid',
|
"funkwhale_api.providers.acoustid",
|
||||||
'funkwhale_api.subsonic',
|
"funkwhale_api.subsonic",
|
||||||
)
|
)
|
||||||
|
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
||||||
|
@ -145,20 +139,18 @@ INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
MIDDLEWARE = (
|
MIDDLEWARE = (
|
||||||
# Make sure djangosecure.middleware.SecurityMiddleware is listed first
|
# Make sure djangosecure.middleware.SecurityMiddleware is listed first
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
'corsheaders.middleware.CorsMiddleware',
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
'django.middleware.common.CommonMiddleware',
|
"django.middleware.common.CommonMiddleware",
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
)
|
)
|
||||||
|
|
||||||
# MIGRATIONS CONFIGURATION
|
# MIGRATIONS CONFIGURATION
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
MIGRATION_MODULES = {
|
MIGRATION_MODULES = {"sites": "funkwhale_api.contrib.sites.migrations"}
|
||||||
'sites': 'funkwhale_api.contrib.sites.migrations'
|
|
||||||
}
|
|
||||||
|
|
||||||
# DEBUG
|
# DEBUG
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
@ -168,9 +160,7 @@ DEBUG = env.bool("DJANGO_DEBUG", False)
|
||||||
# FIXTURE CONFIGURATION
|
# FIXTURE CONFIGURATION
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-FIXTURE_DIRS
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-FIXTURE_DIRS
|
||||||
FIXTURE_DIRS = (
|
FIXTURE_DIRS = (str(APPS_DIR.path("fixtures")),)
|
||||||
str(APPS_DIR.path('fixtures')),
|
|
||||||
)
|
|
||||||
|
|
||||||
# EMAIL CONFIGURATION
|
# EMAIL CONFIGURATION
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
@ -178,16 +168,14 @@ FIXTURE_DIRS = (
|
||||||
# EMAIL
|
# EMAIL
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
DEFAULT_FROM_EMAIL = env(
|
DEFAULT_FROM_EMAIL = env(
|
||||||
'DEFAULT_FROM_EMAIL',
|
"DEFAULT_FROM_EMAIL", default="Funkwhale <noreply@{}>".format(FUNKWHALE_HOSTNAME)
|
||||||
default='Funkwhale <noreply@{}>'.format(FUNKWHALE_HOSTNAME))
|
)
|
||||||
|
|
||||||
EMAIL_SUBJECT_PREFIX = env(
|
EMAIL_SUBJECT_PREFIX = env("EMAIL_SUBJECT_PREFIX", default="[Funkwhale] ")
|
||||||
"EMAIL_SUBJECT_PREFIX", default='[Funkwhale] ')
|
SERVER_EMAIL = env("SERVER_EMAIL", default=DEFAULT_FROM_EMAIL)
|
||||||
SERVER_EMAIL = env('SERVER_EMAIL', default=DEFAULT_FROM_EMAIL)
|
|
||||||
|
|
||||||
|
|
||||||
EMAIL_CONFIG = env.email_url(
|
EMAIL_CONFIG = env.email_url("EMAIL_CONFIG", default="consolemail://")
|
||||||
'EMAIL_CONFIG', default='consolemail://')
|
|
||||||
|
|
||||||
vars().update(EMAIL_CONFIG)
|
vars().update(EMAIL_CONFIG)
|
||||||
|
|
||||||
|
@ -196,9 +184,9 @@ vars().update(EMAIL_CONFIG)
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#databases
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#databases
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
# Raises ImproperlyConfigured exception if DATABASE_URL not in os.environ
|
# Raises ImproperlyConfigured exception if DATABASE_URL not in os.environ
|
||||||
'default': env.db("DATABASE_URL"),
|
"default": env.db("DATABASE_URL")
|
||||||
}
|
}
|
||||||
DATABASES['default']['ATOMIC_REQUESTS'] = True
|
DATABASES["default"]["ATOMIC_REQUESTS"] = True
|
||||||
#
|
#
|
||||||
# DATABASES = {
|
# DATABASES = {
|
||||||
# 'default': {
|
# 'default': {
|
||||||
|
@ -212,10 +200,10 @@ DATABASES['default']['ATOMIC_REQUESTS'] = True
|
||||||
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
|
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
|
||||||
# although not all choices may be available on all operating systems.
|
# although not all choices may be available on all operating systems.
|
||||||
# In a Windows environment this must be set to your system time zone.
|
# In a Windows environment this must be set to your system time zone.
|
||||||
TIME_ZONE = 'UTC'
|
TIME_ZONE = "UTC"
|
||||||
|
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#language-code
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#language-code
|
||||||
LANGUAGE_CODE = 'en-us'
|
LANGUAGE_CODE = "en-us"
|
||||||
|
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#site-id
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#site-id
|
||||||
SITE_ID = 1
|
SITE_ID = 1
|
||||||
|
@ -235,113 +223,105 @@ USE_TZ = True
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-TEMPLATES-BACKEND
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-TEMPLATES-BACKEND
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#template-dirs
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#template-dirs
|
||||||
'DIRS': [
|
"DIRS": [str(APPS_DIR.path("templates"))],
|
||||||
str(APPS_DIR.path('templates')),
|
"OPTIONS": {
|
||||||
],
|
|
||||||
'OPTIONS': {
|
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#template-debug
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#template-debug
|
||||||
'debug': DEBUG,
|
"debug": DEBUG,
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#template-loaders
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#template-loaders
|
||||||
# https://docs.djangoproject.com/en/dev/ref/templates/api/#loader-types
|
# https://docs.djangoproject.com/en/dev/ref/templates/api/#loader-types
|
||||||
'loaders': [
|
"loaders": [
|
||||||
'django.template.loaders.filesystem.Loader',
|
"django.template.loaders.filesystem.Loader",
|
||||||
'django.template.loaders.app_directories.Loader',
|
"django.template.loaders.app_directories.Loader",
|
||||||
],
|
],
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#template-context-processors
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#template-context-processors
|
||||||
'context_processors': [
|
"context_processors": [
|
||||||
'django.template.context_processors.debug',
|
"django.template.context_processors.debug",
|
||||||
'django.template.context_processors.request',
|
"django.template.context_processors.request",
|
||||||
'django.contrib.auth.context_processors.auth',
|
"django.contrib.auth.context_processors.auth",
|
||||||
'django.template.context_processors.i18n',
|
"django.template.context_processors.i18n",
|
||||||
'django.template.context_processors.media',
|
"django.template.context_processors.media",
|
||||||
'django.template.context_processors.static',
|
"django.template.context_processors.static",
|
||||||
'django.template.context_processors.tz',
|
"django.template.context_processors.tz",
|
||||||
'django.contrib.messages.context_processors.messages',
|
"django.contrib.messages.context_processors.messages",
|
||||||
# Your stuff: custom template context processors go here
|
# Your stuff: custom template context processors go here
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
# See: http://django-crispy-forms.readthedocs.org/en/latest/install.html#template-packs
|
# See: http://django-crispy-forms.readthedocs.org/en/latest/install.html#template-packs
|
||||||
CRISPY_TEMPLATE_PACK = 'bootstrap3'
|
CRISPY_TEMPLATE_PACK = "bootstrap3"
|
||||||
|
|
||||||
# STATIC FILE CONFIGURATION
|
# STATIC FILE CONFIGURATION
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#static-root
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#static-root
|
||||||
STATIC_ROOT = env("STATIC_ROOT", default=str(ROOT_DIR('staticfiles')))
|
STATIC_ROOT = env("STATIC_ROOT", default=str(ROOT_DIR("staticfiles")))
|
||||||
|
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#static-url
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#static-url
|
||||||
STATIC_URL = env("STATIC_URL", default='/staticfiles/')
|
STATIC_URL = env("STATIC_URL", default="/staticfiles/")
|
||||||
DEFAULT_FILE_STORAGE = 'funkwhale_api.common.storage.ASCIIFileSystemStorage'
|
DEFAULT_FILE_STORAGE = "funkwhale_api.common.storage.ASCIIFileSystemStorage"
|
||||||
|
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS
|
# See: https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS
|
||||||
STATICFILES_DIRS = (
|
STATICFILES_DIRS = (str(APPS_DIR.path("static")),)
|
||||||
str(APPS_DIR.path('static')),
|
|
||||||
)
|
|
||||||
|
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#staticfiles-finders
|
# See: https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#staticfiles-finders
|
||||||
STATICFILES_FINDERS = (
|
STATICFILES_FINDERS = (
|
||||||
'django.contrib.staticfiles.finders.FileSystemFinder',
|
"django.contrib.staticfiles.finders.FileSystemFinder",
|
||||||
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
|
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
||||||
)
|
)
|
||||||
|
|
||||||
# MEDIA CONFIGURATION
|
# MEDIA CONFIGURATION
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#media-root
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#media-root
|
||||||
MEDIA_ROOT = env("MEDIA_ROOT", default=str(APPS_DIR('media')))
|
MEDIA_ROOT = env("MEDIA_ROOT", default=str(APPS_DIR("media")))
|
||||||
|
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#media-url
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#media-url
|
||||||
MEDIA_URL = env("MEDIA_URL", default='/media/')
|
MEDIA_URL = env("MEDIA_URL", default="/media/")
|
||||||
|
|
||||||
# URL Configuration
|
# URL Configuration
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
ROOT_URLCONF = 'config.urls'
|
ROOT_URLCONF = "config.urls"
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application
|
||||||
WSGI_APPLICATION = 'config.wsgi.application'
|
WSGI_APPLICATION = "config.wsgi.application"
|
||||||
ASGI_APPLICATION = "config.routing.application"
|
ASGI_APPLICATION = "config.routing.application"
|
||||||
|
|
||||||
# This ensures that Django will be able to detect a secure connection
|
# This ensures that Django will be able to detect a secure connection
|
||||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||||
|
|
||||||
# AUTHENTICATION CONFIGURATION
|
# AUTHENTICATION CONFIGURATION
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
AUTHENTICATION_BACKENDS = (
|
AUTHENTICATION_BACKENDS = (
|
||||||
'django.contrib.auth.backends.ModelBackend',
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
'allauth.account.auth_backends.AuthenticationBackend',
|
"allauth.account.auth_backends.AuthenticationBackend",
|
||||||
)
|
)
|
||||||
SESSION_COOKIE_HTTPONLY = False
|
SESSION_COOKIE_HTTPONLY = False
|
||||||
# Some really nice defaults
|
# Some really nice defaults
|
||||||
ACCOUNT_AUTHENTICATION_METHOD = 'username_email'
|
ACCOUNT_AUTHENTICATION_METHOD = "username_email"
|
||||||
ACCOUNT_EMAIL_REQUIRED = True
|
ACCOUNT_EMAIL_REQUIRED = True
|
||||||
ACCOUNT_EMAIL_VERIFICATION = 'mandatory'
|
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
|
||||||
|
|
||||||
# Custom user app defaults
|
# Custom user app defaults
|
||||||
# Select the correct user model
|
# Select the correct user model
|
||||||
AUTH_USER_MODEL = 'users.User'
|
AUTH_USER_MODEL = "users.User"
|
||||||
LOGIN_REDIRECT_URL = 'users:redirect'
|
LOGIN_REDIRECT_URL = "users:redirect"
|
||||||
LOGIN_URL = 'account_login'
|
LOGIN_URL = "account_login"
|
||||||
|
|
||||||
# SLUGLIFIER
|
# SLUGLIFIER
|
||||||
AUTOSLUG_SLUGIFY_FUNCTION = 'slugify.slugify'
|
AUTOSLUG_SLUGIFY_FUNCTION = "slugify.slugify"
|
||||||
|
|
||||||
CACHE_DEFAULT = "redis://127.0.0.1:6379/0"
|
CACHE_DEFAULT = "redis://127.0.0.1:6379/0"
|
||||||
CACHES = {
|
CACHES = {"default": env.cache_url("CACHE_URL", default=CACHE_DEFAULT)}
|
||||||
"default": env.cache_url('CACHE_URL', default=CACHE_DEFAULT)
|
|
||||||
}
|
|
||||||
|
|
||||||
CACHES["default"]["BACKEND"] = "django_redis.cache.RedisCache"
|
CACHES["default"]["BACKEND"] = "django_redis.cache.RedisCache"
|
||||||
from urllib.parse import urlparse
|
|
||||||
cache_url = urlparse(CACHES['default']['LOCATION'])
|
cache_url = urlparse(CACHES["default"]["LOCATION"])
|
||||||
CHANNEL_LAYERS = {
|
CHANNEL_LAYERS = {
|
||||||
"default": {
|
"default": {
|
||||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||||
"CONFIG": {
|
"CONFIG": {"hosts": [(cache_url.hostname, cache_url.port)]},
|
||||||
"hosts": [(cache_url.hostname, cache_url.port)],
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CACHES["default"]["OPTIONS"] = {
|
CACHES["default"]["OPTIONS"] = {
|
||||||
|
@ -351,36 +331,34 @@ CACHES["default"]["OPTIONS"] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
########## CELERY
|
# CELERY
|
||||||
INSTALLED_APPS += ('funkwhale_api.taskapp.celery.CeleryConfig',)
|
INSTALLED_APPS += ("funkwhale_api.taskapp.celery.CeleryConfig",)
|
||||||
CELERY_BROKER_URL = env(
|
CELERY_BROKER_URL = env(
|
||||||
"CELERY_BROKER_URL", default=env('CACHE_URL', default=CACHE_DEFAULT))
|
"CELERY_BROKER_URL", default=env("CACHE_URL", default=CACHE_DEFAULT)
|
||||||
########## END CELERY
|
)
|
||||||
|
# END CELERY
|
||||||
# Location of root django.contrib.admin URL, use {% url 'admin:index' %}
|
# Location of root django.contrib.admin URL, use {% url 'admin:index' %}
|
||||||
|
|
||||||
# Your common stuff: Below this line define 3rd party library settings
|
# Your common stuff: Below this line define 3rd party library settings
|
||||||
CELERY_TASK_DEFAULT_RATE_LIMIT = 1
|
CELERY_TASK_DEFAULT_RATE_LIMIT = 1
|
||||||
CELERY_TASK_TIME_LIMIT = 300
|
CELERY_TASK_TIME_LIMIT = 300
|
||||||
CELERYBEAT_SCHEDULE = {
|
CELERYBEAT_SCHEDULE = {
|
||||||
'federation.clean_music_cache': {
|
"federation.clean_music_cache": {
|
||||||
'task': 'funkwhale_api.federation.tasks.clean_music_cache',
|
"task": "funkwhale_api.federation.tasks.clean_music_cache",
|
||||||
'schedule': crontab(hour='*/2'),
|
"schedule": crontab(hour="*/2"),
|
||||||
'options': {
|
"options": {"expires": 60 * 2},
|
||||||
'expires': 60 * 2,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
import datetime
|
|
||||||
JWT_AUTH = {
|
JWT_AUTH = {
|
||||||
'JWT_ALLOW_REFRESH': True,
|
"JWT_ALLOW_REFRESH": True,
|
||||||
'JWT_EXPIRATION_DELTA': datetime.timedelta(days=7),
|
"JWT_EXPIRATION_DELTA": datetime.timedelta(days=7),
|
||||||
'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=30),
|
"JWT_REFRESH_EXPIRATION_DELTA": datetime.timedelta(days=30),
|
||||||
'JWT_AUTH_HEADER_PREFIX': 'JWT',
|
"JWT_AUTH_HEADER_PREFIX": "JWT",
|
||||||
'JWT_GET_USER_SECRET_KEY': lambda user: user.secret_key
|
"JWT_GET_USER_SECRET_KEY": lambda user: user.secret_key,
|
||||||
}
|
}
|
||||||
OLD_PASSWORD_FIELD_ENABLED = True
|
OLD_PASSWORD_FIELD_ENABLED = True
|
||||||
ACCOUNT_ADAPTER = 'funkwhale_api.users.adapters.FunkwhaleAccountAdapter'
|
ACCOUNT_ADAPTER = "funkwhale_api.users.adapters.FunkwhaleAccountAdapter"
|
||||||
CORS_ORIGIN_ALLOW_ALL = True
|
CORS_ORIGIN_ALLOW_ALL = True
|
||||||
# CORS_ORIGIN_WHITELIST = (
|
# CORS_ORIGIN_WHITELIST = (
|
||||||
# 'localhost',
|
# 'localhost',
|
||||||
|
@ -389,41 +367,37 @@ CORS_ORIGIN_ALLOW_ALL = True
|
||||||
CORS_ALLOW_CREDENTIALS = True
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
|
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
'DEFAULT_PERMISSION_CLASSES': (
|
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
|
||||||
'rest_framework.permissions.IsAuthenticated',
|
"DEFAULT_PAGINATION_CLASS": "funkwhale_api.common.pagination.FunkwhalePagination",
|
||||||
|
"PAGE_SIZE": 25,
|
||||||
|
"DEFAULT_PARSER_CLASSES": (
|
||||||
|
"rest_framework.parsers.JSONParser",
|
||||||
|
"rest_framework.parsers.FormParser",
|
||||||
|
"rest_framework.parsers.MultiPartParser",
|
||||||
|
"funkwhale_api.federation.parsers.ActivityParser",
|
||||||
),
|
),
|
||||||
'DEFAULT_PAGINATION_CLASS': 'funkwhale_api.common.pagination.FunkwhalePagination',
|
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||||
'PAGE_SIZE': 25,
|
"funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS",
|
||||||
'DEFAULT_PARSER_CLASSES': (
|
"funkwhale_api.common.authentication.BearerTokenHeaderAuth",
|
||||||
'rest_framework.parsers.JSONParser',
|
"rest_framework_jwt.authentication.JSONWebTokenAuthentication",
|
||||||
'rest_framework.parsers.FormParser',
|
"rest_framework.authentication.SessionAuthentication",
|
||||||
'rest_framework.parsers.MultiPartParser',
|
"rest_framework.authentication.BasicAuthentication",
|
||||||
'funkwhale_api.federation.parsers.ActivityParser',
|
|
||||||
),
|
),
|
||||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
"DEFAULT_FILTER_BACKENDS": (
|
||||||
'funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS',
|
"rest_framework.filters.OrderingFilter",
|
||||||
'funkwhale_api.common.authentication.BearerTokenHeaderAuth',
|
"django_filters.rest_framework.DjangoFilterBackend",
|
||||||
'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
|
|
||||||
'rest_framework.authentication.SessionAuthentication',
|
|
||||||
'rest_framework.authentication.BasicAuthentication',
|
|
||||||
),
|
),
|
||||||
'DEFAULT_FILTER_BACKENDS': (
|
"DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
|
||||||
'rest_framework.filters.OrderingFilter',
|
|
||||||
'django_filters.rest_framework.DjangoFilterBackend',
|
|
||||||
),
|
|
||||||
'DEFAULT_RENDERER_CLASSES': (
|
|
||||||
'rest_framework.renderers.JSONRenderer',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
BROWSABLE_API_ENABLED = env.bool('BROWSABLE_API_ENABLED', default=False)
|
BROWSABLE_API_ENABLED = env.bool("BROWSABLE_API_ENABLED", default=False)
|
||||||
if BROWSABLE_API_ENABLED:
|
if BROWSABLE_API_ENABLED:
|
||||||
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] += (
|
REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] += (
|
||||||
'rest_framework.renderers.BrowsableAPIRenderer',
|
"rest_framework.renderers.BrowsableAPIRenderer",
|
||||||
)
|
)
|
||||||
|
|
||||||
REST_AUTH_SERIALIZERS = {
|
REST_AUTH_SERIALIZERS = {
|
||||||
'PASSWORD_RESET_SERIALIZER': 'funkwhale_api.users.serializers.PasswordResetSerializer' # noqa
|
"PASSWORD_RESET_SERIALIZER": "funkwhale_api.users.serializers.PasswordResetSerializer" # noqa
|
||||||
}
|
}
|
||||||
REST_SESSION_LOGIN = False
|
REST_SESSION_LOGIN = False
|
||||||
REST_USE_JWT = True
|
REST_USE_JWT = True
|
||||||
|
@ -434,60 +408,55 @@ USE_X_FORWARDED_PORT = True
|
||||||
|
|
||||||
# Wether we should use Apache, Nginx (or other) headers when serving audio files
|
# Wether we should use Apache, Nginx (or other) headers when serving audio files
|
||||||
# Default to Nginx
|
# Default to Nginx
|
||||||
REVERSE_PROXY_TYPE = env('REVERSE_PROXY_TYPE', default='nginx')
|
REVERSE_PROXY_TYPE = env("REVERSE_PROXY_TYPE", default="nginx")
|
||||||
assert REVERSE_PROXY_TYPE in ['apache2', 'nginx'], 'Unsupported REVERSE_PROXY_TYPE'
|
assert REVERSE_PROXY_TYPE in ["apache2", "nginx"], "Unsupported REVERSE_PROXY_TYPE"
|
||||||
|
|
||||||
# Which path will be used to process the internal redirection
|
# Which path will be used to process the internal redirection
|
||||||
# **DO NOT** put a slash at the end
|
# **DO NOT** put a slash at the end
|
||||||
PROTECT_FILES_PATH = env('PROTECT_FILES_PATH', default='/_protected')
|
PROTECT_FILES_PATH = env("PROTECT_FILES_PATH", default="/_protected")
|
||||||
|
|
||||||
|
|
||||||
# use this setting to tweak for how long you want to cache
|
# use this setting to tweak for how long you want to cache
|
||||||
# musicbrainz results. (value is in seconds)
|
# musicbrainz results. (value is in seconds)
|
||||||
MUSICBRAINZ_CACHE_DURATION = env.int(
|
MUSICBRAINZ_CACHE_DURATION = env.int("MUSICBRAINZ_CACHE_DURATION", default=300)
|
||||||
'MUSICBRAINZ_CACHE_DURATION',
|
CACHEOPS_REDIS = env("CACHE_URL", default=CACHE_DEFAULT)
|
||||||
default=300
|
CACHEOPS_ENABLED = env.bool("CACHEOPS_ENABLED", default=True)
|
||||||
)
|
|
||||||
CACHEOPS_REDIS = env('CACHE_URL', default=CACHE_DEFAULT)
|
|
||||||
CACHEOPS_ENABLED = env.bool('CACHEOPS_ENABLED', default=True)
|
|
||||||
CACHEOPS = {
|
CACHEOPS = {
|
||||||
'music.artist': {'ops': 'all', 'timeout': 60 * 60},
|
"music.artist": {"ops": "all", "timeout": 60 * 60},
|
||||||
'music.album': {'ops': 'all', 'timeout': 60 * 60},
|
"music.album": {"ops": "all", "timeout": 60 * 60},
|
||||||
'music.track': {'ops': 'all', 'timeout': 60 * 60},
|
"music.track": {"ops": "all", "timeout": 60 * 60},
|
||||||
'music.trackfile': {'ops': 'all', 'timeout': 60 * 60},
|
"music.trackfile": {"ops": "all", "timeout": 60 * 60},
|
||||||
'taggit.tag': {'ops': 'all', 'timeout': 60 * 60},
|
"taggit.tag": {"ops": "all", "timeout": 60 * 60},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Custom Admin URL, use {% url 'admin:index' %}
|
# Custom Admin URL, use {% url 'admin:index' %}
|
||||||
ADMIN_URL = env('DJANGO_ADMIN_URL', default='^api/admin/')
|
ADMIN_URL = env("DJANGO_ADMIN_URL", default="^api/admin/")
|
||||||
CSRF_USE_SESSIONS = True
|
CSRF_USE_SESSIONS = True
|
||||||
|
|
||||||
# Playlist settings
|
# Playlist settings
|
||||||
# XXX: deprecated, see #186
|
# XXX: deprecated, see #186
|
||||||
PLAYLISTS_MAX_TRACKS = env.int('PLAYLISTS_MAX_TRACKS', default=250)
|
PLAYLISTS_MAX_TRACKS = env.int("PLAYLISTS_MAX_TRACKS", default=250)
|
||||||
|
|
||||||
ACCOUNT_USERNAME_BLACKLIST = [
|
ACCOUNT_USERNAME_BLACKLIST = [
|
||||||
'funkwhale',
|
"funkwhale",
|
||||||
'library',
|
"library",
|
||||||
'test',
|
"test",
|
||||||
'status',
|
"status",
|
||||||
'root',
|
"root",
|
||||||
'admin',
|
"admin",
|
||||||
'owner',
|
"owner",
|
||||||
'superuser',
|
"superuser",
|
||||||
'staff',
|
"staff",
|
||||||
'service',
|
"service",
|
||||||
] + env.list('ACCOUNT_USERNAME_BLACKLIST', default=[])
|
] + env.list("ACCOUNT_USERNAME_BLACKLIST", default=[])
|
||||||
|
|
||||||
EXTERNAL_REQUESTS_VERIFY_SSL = env.bool(
|
EXTERNAL_REQUESTS_VERIFY_SSL = env.bool("EXTERNAL_REQUESTS_VERIFY_SSL", default=True)
|
||||||
'EXTERNAL_REQUESTS_VERIFY_SSL',
|
|
||||||
default=True
|
|
||||||
)
|
|
||||||
# XXX: deprecated, see #186
|
# XXX: deprecated, see #186
|
||||||
API_AUTHENTICATION_REQUIRED = env.bool("API_AUTHENTICATION_REQUIRED", True)
|
API_AUTHENTICATION_REQUIRED = env.bool("API_AUTHENTICATION_REQUIRED", True)
|
||||||
|
|
||||||
MUSIC_DIRECTORY_PATH = env('MUSIC_DIRECTORY_PATH', default=None)
|
MUSIC_DIRECTORY_PATH = env("MUSIC_DIRECTORY_PATH", default=None)
|
||||||
# on Docker setup, the music directory may not match the host path,
|
# on Docker setup, the music directory may not match the host path,
|
||||||
# and we need to know it for it to serve stuff properly
|
# and we need to know it for it to serve stuff properly
|
||||||
MUSIC_DIRECTORY_SERVE_PATH = env(
|
MUSIC_DIRECTORY_SERVE_PATH = env(
|
||||||
'MUSIC_DIRECTORY_SERVE_PATH', default=MUSIC_DIRECTORY_PATH)
|
"MUSIC_DIRECTORY_SERVE_PATH", default=MUSIC_DIRECTORY_PATH
|
||||||
|
)
|
||||||
|
|
|
@ -1,79 +1,72 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
'''
|
"""
|
||||||
Local settings
|
Local settings
|
||||||
|
|
||||||
- Run in Debug mode
|
- Run in Debug mode
|
||||||
- Use console backend for emails
|
- Use console backend for emails
|
||||||
- Add Django Debug Toolbar
|
- Add Django Debug Toolbar
|
||||||
- Add django-extensions as app
|
- Add django-extensions as app
|
||||||
'''
|
"""
|
||||||
|
|
||||||
from .common import * # noqa
|
from .common import * # noqa
|
||||||
|
|
||||||
|
|
||||||
# DEBUG
|
# DEBUG
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
DEBUG = env.bool('DJANGO_DEBUG', default=True)
|
DEBUG = env.bool("DJANGO_DEBUG", default=True)
|
||||||
TEMPLATES[0]['OPTIONS']['debug'] = DEBUG
|
TEMPLATES[0]["OPTIONS"]["debug"] = DEBUG
|
||||||
|
|
||||||
# SECRET CONFIGURATION
|
# SECRET CONFIGURATION
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
|
||||||
# Note: This key only used for development and testing.
|
# Note: This key only used for development and testing.
|
||||||
SECRET_KEY = env("DJANGO_SECRET_KEY", default='mc$&b=5j#6^bv7tld1gyjp2&+^-qrdy=0sw@r5sua*1zp4fmxc')
|
SECRET_KEY = env(
|
||||||
|
"DJANGO_SECRET_KEY", default="mc$&b=5j#6^bv7tld1gyjp2&+^-qrdy=0sw@r5sua*1zp4fmxc"
|
||||||
|
)
|
||||||
|
|
||||||
# Mail settings
|
# Mail settings
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
EMAIL_HOST = 'localhost'
|
EMAIL_HOST = "localhost"
|
||||||
EMAIL_PORT = 1025
|
EMAIL_PORT = 1025
|
||||||
|
|
||||||
# django-debug-toolbar
|
# django-debug-toolbar
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
MIDDLEWARE += ('debug_toolbar.middleware.DebugToolbarMiddleware',)
|
MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",)
|
||||||
|
|
||||||
# INTERNAL_IPS = ('127.0.0.1', '10.0.2.2',)
|
# INTERNAL_IPS = ('127.0.0.1', '10.0.2.2',)
|
||||||
|
|
||||||
DEBUG_TOOLBAR_CONFIG = {
|
DEBUG_TOOLBAR_CONFIG = {
|
||||||
'DISABLE_PANELS': [
|
"DISABLE_PANELS": ["debug_toolbar.panels.redirects.RedirectsPanel"],
|
||||||
'debug_toolbar.panels.redirects.RedirectsPanel',
|
"SHOW_TEMPLATE_CONTEXT": True,
|
||||||
],
|
"SHOW_TOOLBAR_CALLBACK": lambda request: True,
|
||||||
'SHOW_TEMPLATE_CONTEXT': True,
|
|
||||||
'SHOW_TOOLBAR_CALLBACK': lambda request: True,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# django-extensions
|
# django-extensions
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# INSTALLED_APPS += ('django_extensions', )
|
# INSTALLED_APPS += ('django_extensions', )
|
||||||
INSTALLED_APPS += ('debug_toolbar', )
|
INSTALLED_APPS += ("debug_toolbar",)
|
||||||
|
|
||||||
# TESTING
|
# TESTING
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
TEST_RUNNER = 'django.test.runner.DiscoverRunner'
|
TEST_RUNNER = "django.test.runner.DiscoverRunner"
|
||||||
|
|
||||||
########## CELERY
|
# CELERY
|
||||||
# In development, all tasks will be executed locally by blocking until the task returns
|
# In development, all tasks will be executed locally by blocking until the task returns
|
||||||
CELERY_TASK_ALWAYS_EAGER = False
|
CELERY_TASK_ALWAYS_EAGER = False
|
||||||
########## END CELERY
|
# END CELERY
|
||||||
|
|
||||||
# Your local stuff: Below this line define 3rd party library settings
|
# Your local stuff: Below this line define 3rd party library settings
|
||||||
|
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
'version': 1,
|
"version": 1,
|
||||||
'handlers': {
|
"handlers": {"console": {"level": "DEBUG", "class": "logging.StreamHandler"}},
|
||||||
'console':{
|
"loggers": {
|
||||||
'level':'DEBUG',
|
"django.request": {
|
||||||
'class':'logging.StreamHandler',
|
"handlers": ["console"],
|
||||||
},
|
"propagate": True,
|
||||||
},
|
"level": "DEBUG",
|
||||||
'loggers': {
|
|
||||||
'django.request': {
|
|
||||||
'handlers':['console'],
|
|
||||||
'propagate': True,
|
|
||||||
'level':'DEBUG',
|
|
||||||
},
|
|
||||||
'': {
|
|
||||||
'level': 'DEBUG',
|
|
||||||
'handlers': ['console'],
|
|
||||||
},
|
},
|
||||||
|
"": {"level": "DEBUG", "handlers": ["console"]},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
CSRF_TRUSTED_ORIGINS = [o for o in ALLOWED_HOSTS]
|
CSRF_TRUSTED_ORIGINS = [o for o in ALLOWED_HOSTS]
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
'''
|
"""
|
||||||
Production Configurations
|
Production Configurations
|
||||||
|
|
||||||
- Use djangosecure
|
- Use djangosecure
|
||||||
|
@ -8,12 +8,9 @@ Production Configurations
|
||||||
- Use Redis on Heroku
|
- Use Redis on Heroku
|
||||||
|
|
||||||
|
|
||||||
'''
|
"""
|
||||||
from __future__ import absolute_import, unicode_literals
|
from __future__ import absolute_import, unicode_literals
|
||||||
|
|
||||||
from django.utils import six
|
|
||||||
|
|
||||||
|
|
||||||
from .common import * # noqa
|
from .common import * # noqa
|
||||||
|
|
||||||
# SECRET CONFIGURATION
|
# SECRET CONFIGURATION
|
||||||
|
@ -58,19 +55,24 @@ CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# Uploaded Media Files
|
# Uploaded Media Files
|
||||||
# ------------------------
|
# ------------------------
|
||||||
DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
|
DEFAULT_FILE_STORAGE = "django.core.files.storage.FileSystemStorage"
|
||||||
|
|
||||||
# Static Assets
|
# Static Assets
|
||||||
# ------------------------
|
# ------------------------
|
||||||
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.StaticFilesStorage'
|
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||||
|
|
||||||
# TEMPLATE CONFIGURATION
|
# TEMPLATE CONFIGURATION
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# See:
|
# See:
|
||||||
# https://docs.djangoproject.com/en/dev/ref/templates/api/#django.template.loaders.cached.Loader
|
# https://docs.djangoproject.com/en/dev/ref/templates/api/#django.template.loaders.cached.Loader
|
||||||
TEMPLATES[0]['OPTIONS']['loaders'] = [
|
TEMPLATES[0]["OPTIONS"]["loaders"] = [
|
||||||
('django.template.loaders.cached.Loader', [
|
(
|
||||||
'django.template.loaders.filesystem.Loader', 'django.template.loaders.app_directories.Loader', ]),
|
"django.template.loaders.cached.Loader",
|
||||||
|
[
|
||||||
|
"django.template.loaders.filesystem.Loader",
|
||||||
|
"django.template.loaders.app_directories.Loader",
|
||||||
|
],
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
# CACHING
|
# CACHING
|
||||||
|
@ -78,7 +80,6 @@ TEMPLATES[0]['OPTIONS']['loaders'] = [
|
||||||
# Heroku URL does not pass the DB number, so we parse it in
|
# Heroku URL does not pass the DB number, so we parse it in
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# LOGGING CONFIGURATION
|
# LOGGING CONFIGURATION
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#logging
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#logging
|
||||||
|
@ -88,43 +89,39 @@ TEMPLATES[0]['OPTIONS']['loaders'] = [
|
||||||
# See http://docs.djangoproject.com/en/dev/topics/logging for
|
# See http://docs.djangoproject.com/en/dev/topics/logging for
|
||||||
# more details on how to customize your logging configuration.
|
# more details on how to customize your logging configuration.
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
'version': 1,
|
"version": 1,
|
||||||
'disable_existing_loggers': False,
|
"disable_existing_loggers": False,
|
||||||
'filters': {
|
"filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}},
|
||||||
'require_debug_false': {
|
"formatters": {
|
||||||
'()': 'django.utils.log.RequireDebugFalse'
|
"verbose": {
|
||||||
|
"format": "%(levelname)s %(asctime)s %(module)s "
|
||||||
|
"%(process)d %(thread)d %(message)s"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'formatters': {
|
"handlers": {
|
||||||
'verbose': {
|
"mail_admins": {
|
||||||
'format': '%(levelname)s %(asctime)s %(module)s '
|
"level": "ERROR",
|
||||||
'%(process)d %(thread)d %(message)s'
|
"filters": ["require_debug_false"],
|
||||||
|
"class": "django.utils.log.AdminEmailHandler",
|
||||||
|
},
|
||||||
|
"console": {
|
||||||
|
"level": "DEBUG",
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "verbose",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'handlers': {
|
"loggers": {
|
||||||
'mail_admins': {
|
"django.request": {
|
||||||
'level': 'ERROR',
|
"handlers": ["mail_admins"],
|
||||||
'filters': ['require_debug_false'],
|
"level": "ERROR",
|
||||||
'class': 'django.utils.log.AdminEmailHandler'
|
"propagate": True,
|
||||||
},
|
},
|
||||||
'console': {
|
"django.security.DisallowedHost": {
|
||||||
'level': 'DEBUG',
|
"level": "ERROR",
|
||||||
'class': 'logging.StreamHandler',
|
"handlers": ["console", "mail_admins"],
|
||||||
'formatter': 'verbose',
|
"propagate": True,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'loggers': {
|
|
||||||
'django.request': {
|
|
||||||
'handlers': ['mail_admins'],
|
|
||||||
'level': 'ERROR',
|
|
||||||
'propagate': True
|
|
||||||
},
|
|
||||||
'django.security.DisallowedHost': {
|
|
||||||
'level': 'ERROR',
|
|
||||||
'handlers': ['console', 'mail_admins'],
|
|
||||||
'propagate': True
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -5,38 +5,35 @@ from django.conf import settings
|
||||||
from django.conf.urls import include, url
|
from django.conf.urls import include, url
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.views.generic import TemplateView
|
|
||||||
from django.views import defaults as default_views
|
from django.views import defaults as default_views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Django Admin, use {% url 'admin:index' %}
|
# Django Admin, use {% url 'admin:index' %}
|
||||||
url(settings.ADMIN_URL, admin.site.urls),
|
url(settings.ADMIN_URL, admin.site.urls),
|
||||||
|
url(r"^api/", include(("config.api_urls", "api"), namespace="api")),
|
||||||
url(r'^api/', include(("config.api_urls", 'api'), namespace="api")),
|
url(
|
||||||
url(r'^', include(
|
r"^",
|
||||||
('funkwhale_api.federation.urls', 'federation'),
|
include(
|
||||||
namespace="federation")),
|
("funkwhale_api.federation.urls", "federation"), namespace="federation"
|
||||||
url(r'^api/v1/auth/', include('rest_auth.urls')),
|
),
|
||||||
url(r'^api/v1/auth/registration/', include('funkwhale_api.users.rest_auth_urls')),
|
),
|
||||||
url(r'^accounts/', include('allauth.urls')),
|
url(r"^api/v1/auth/", include("rest_auth.urls")),
|
||||||
|
url(r"^api/v1/auth/registration/", include("funkwhale_api.users.rest_auth_urls")),
|
||||||
|
url(r"^accounts/", include("allauth.urls")),
|
||||||
# Your stuff: custom urls includes go here
|
# Your stuff: custom urls includes go here
|
||||||
|
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
# This allows the error pages to be debugged during development, just visit
|
# This allows the error pages to be debugged during development, just visit
|
||||||
# these url in browser to see how these error pages look like.
|
# these url in browser to see how these error pages look like.
|
||||||
urlpatterns += [
|
urlpatterns += [
|
||||||
url(r'^400/$', default_views.bad_request),
|
url(r"^400/$", default_views.bad_request),
|
||||||
url(r'^403/$', default_views.permission_denied),
|
url(r"^403/$", default_views.permission_denied),
|
||||||
url(r'^404/$', default_views.page_not_found),
|
url(r"^404/$", default_views.page_not_found),
|
||||||
url(r'^500/$', default_views.server_error),
|
url(r"^500/$", default_views.server_error),
|
||||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|
||||||
if 'debug_toolbar' in settings.INSTALLED_APPS:
|
if "debug_toolbar" in settings.INSTALLED_APPS:
|
||||||
import debug_toolbar
|
import debug_toolbar
|
||||||
urlpatterns += [
|
|
||||||
url(r'^__debug__/', include(debug_toolbar.urls)),
|
urlpatterns += [url(r"^__debug__/", include(debug_toolbar.urls))]
|
||||||
]
|
|
||||||
|
|
|
@ -15,11 +15,9 @@ framework.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
||||||
from django.core.wsgi import get_wsgi_application
|
from django.core.wsgi import get_wsgi_application
|
||||||
from whitenoise.django import DjangoWhiteNoise
|
from whitenoise.django import DjangoWhiteNoise
|
||||||
|
|
||||||
|
|
||||||
# We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks
|
# We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks
|
||||||
# if running multiple sites in the same mod_wsgi process. To fix this, use
|
# if running multiple sites in the same mod_wsgi process. To fix this, use
|
||||||
# mod_wsgi daemon mode with each site in its own daemon process, or use
|
# mod_wsgi daemon mode with each site in its own daemon process, or use
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
from funkwhale_api.users.models import User
|
from funkwhale_api.users.models import User
|
||||||
|
|
||||||
|
|
||||||
u = User.objects.create(email='demo@demo.com', username='demo', is_staff=True)
|
u = User.objects.create(email="demo@demo.com", username="demo", is_staff=True)
|
||||||
u.set_password('demo')
|
u.set_password("demo")
|
||||||
u.subsonic_api_token = 'demo'
|
u.subsonic_api_token = "demo"
|
||||||
u.save()
|
u.save()
|
||||||
|
|
|
@ -1,3 +1,8 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
__version__ = '0.14.1'
|
__version__ = "0.14.2"
|
||||||
__version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])
|
__version_info__ = tuple(
|
||||||
|
[
|
||||||
|
int(num) if num.isdigit() else num
|
||||||
|
for num in __version__.replace("-", ".", 1).split(".")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
|
@ -2,8 +2,9 @@ from django.apps import AppConfig, apps
|
||||||
|
|
||||||
from . import record
|
from . import record
|
||||||
|
|
||||||
|
|
||||||
class ActivityConfig(AppConfig):
|
class ActivityConfig(AppConfig):
|
||||||
name = 'funkwhale_api.activity'
|
name = "funkwhale_api.activity"
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
super(ActivityConfig, self).ready()
|
super(ActivityConfig, self).ready()
|
||||||
|
|
|
@ -2,37 +2,36 @@ import persisting_theory
|
||||||
|
|
||||||
|
|
||||||
class ActivityRegistry(persisting_theory.Registry):
|
class ActivityRegistry(persisting_theory.Registry):
|
||||||
look_into = 'activities'
|
look_into = "activities"
|
||||||
|
|
||||||
def _register_for_model(self, model, attr, value):
|
def _register_for_model(self, model, attr, value):
|
||||||
key = model._meta.label
|
key = model._meta.label
|
||||||
d = self.setdefault(key, {'consumers': []})
|
d = self.setdefault(key, {"consumers": []})
|
||||||
d[attr] = value
|
d[attr] = value
|
||||||
|
|
||||||
def register_serializer(self, serializer_class):
|
def register_serializer(self, serializer_class):
|
||||||
model = serializer_class.Meta.model
|
model = serializer_class.Meta.model
|
||||||
self._register_for_model(model, 'serializer', serializer_class)
|
self._register_for_model(model, "serializer", serializer_class)
|
||||||
return serializer_class
|
return serializer_class
|
||||||
|
|
||||||
def register_consumer(self, label):
|
def register_consumer(self, label):
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
consumers = self[label]['consumers']
|
consumers = self[label]["consumers"]
|
||||||
if func not in consumers:
|
if func not in consumers:
|
||||||
consumers.append(func)
|
consumers.append(func)
|
||||||
return func
|
return func
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
registry = ActivityRegistry()
|
registry = ActivityRegistry()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def send(obj):
|
def send(obj):
|
||||||
conf = registry[obj.__class__._meta.label]
|
conf = registry[obj.__class__._meta.label]
|
||||||
consumers = conf['consumers']
|
consumers = conf["consumers"]
|
||||||
if not consumers:
|
if not consumers:
|
||||||
return
|
return
|
||||||
serializer = conf['serializer'](obj)
|
serializer = conf["serializer"](obj)
|
||||||
for consumer in consumers:
|
for consumer in consumers:
|
||||||
consumer(data=serializer.data, obj=obj)
|
consumer(data=serializer.data, obj=obj)
|
||||||
|
|
|
@ -4,8 +4,8 @@ from funkwhale_api.activity import record
|
||||||
|
|
||||||
|
|
||||||
class ModelSerializer(serializers.ModelSerializer):
|
class ModelSerializer(serializers.ModelSerializer):
|
||||||
id = serializers.CharField(source='get_activity_url')
|
id = serializers.CharField(source="get_activity_url")
|
||||||
local_id = serializers.IntegerField(source='id')
|
local_id = serializers.IntegerField(source="id")
|
||||||
# url = serializers.SerializerMethodField()
|
# url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
def get_url(self, obj):
|
def get_url(self, obj):
|
||||||
|
@ -17,8 +17,7 @@ class AutoSerializer(serializers.Serializer):
|
||||||
A serializer that will automatically use registered activity serializers
|
A serializer that will automatically use registered activity serializers
|
||||||
to serialize an henerogeneous list of objects (favorites, listenings, etc.)
|
to serialize an henerogeneous list of objects (favorites, listenings, etc.)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
serializer = record.registry[instance._meta.label]['serializer'](
|
serializer = record.registry[instance._meta.label]["serializer"](instance)
|
||||||
instance
|
|
||||||
)
|
|
||||||
return serializer.data
|
return serializer.data
|
||||||
|
|
|
@ -6,31 +6,25 @@ from funkwhale_api.history.models import Listening
|
||||||
|
|
||||||
|
|
||||||
def combined_recent(limit, **kwargs):
|
def combined_recent(limit, **kwargs):
|
||||||
datetime_field = kwargs.pop('datetime_field', 'creation_date')
|
datetime_field = kwargs.pop("datetime_field", "creation_date")
|
||||||
source_querysets = {
|
source_querysets = {qs.model._meta.label: qs for qs in kwargs.pop("querysets")}
|
||||||
qs.model._meta.label: qs for qs in kwargs.pop('querysets')
|
|
||||||
}
|
|
||||||
querysets = {
|
querysets = {
|
||||||
k: qs.annotate(
|
k: qs.annotate(
|
||||||
__type=models.Value(
|
__type=models.Value(qs.model._meta.label, output_field=models.CharField())
|
||||||
qs.model._meta.label, output_field=models.CharField()
|
).values("pk", datetime_field, "__type")
|
||||||
)
|
|
||||||
).values('pk', datetime_field, '__type')
|
|
||||||
for k, qs in source_querysets.items()
|
for k, qs in source_querysets.items()
|
||||||
}
|
}
|
||||||
_qs_list = list(querysets.values())
|
_qs_list = list(querysets.values())
|
||||||
union_qs = _qs_list[0].union(*_qs_list[1:])
|
union_qs = _qs_list[0].union(*_qs_list[1:])
|
||||||
records = []
|
records = []
|
||||||
for row in union_qs.order_by('-{}'.format(datetime_field))[:limit]:
|
for row in union_qs.order_by("-{}".format(datetime_field))[:limit]:
|
||||||
records.append({
|
records.append(
|
||||||
'type': row['__type'],
|
{"type": row["__type"], "when": row[datetime_field], "pk": row["pk"]}
|
||||||
'when': row[datetime_field],
|
)
|
||||||
'pk': row['pk']
|
|
||||||
})
|
|
||||||
# Now we bulk-load each object type in turn
|
# Now we bulk-load each object type in turn
|
||||||
to_load = {}
|
to_load = {}
|
||||||
for record in records:
|
for record in records:
|
||||||
to_load.setdefault(record['type'], []).append(record['pk'])
|
to_load.setdefault(record["type"], []).append(record["pk"])
|
||||||
fetched = {}
|
fetched = {}
|
||||||
|
|
||||||
for key, pks in to_load.items():
|
for key, pks in to_load.items():
|
||||||
|
@ -39,26 +33,19 @@ def combined_recent(limit, **kwargs):
|
||||||
|
|
||||||
# Annotate 'records' with loaded objects
|
# Annotate 'records' with loaded objects
|
||||||
for record in records:
|
for record in records:
|
||||||
record['object'] = fetched[(record['type'], record['pk'])]
|
record["object"] = fetched[(record["type"], record["pk"])]
|
||||||
return records
|
return records
|
||||||
|
|
||||||
|
|
||||||
def get_activity(user, limit=20):
|
def get_activity(user, limit=20):
|
||||||
query = fields.privacy_level_query(
|
query = fields.privacy_level_query(user, lookup_field="user__privacy_level")
|
||||||
user, lookup_field='user__privacy_level')
|
|
||||||
querysets = [
|
querysets = [
|
||||||
Listening.objects.filter(query).select_related(
|
Listening.objects.filter(query).select_related(
|
||||||
'track',
|
"track", "user", "track__artist", "track__album__artist"
|
||||||
'user',
|
|
||||||
'track__artist',
|
|
||||||
'track__album__artist',
|
|
||||||
),
|
),
|
||||||
TrackFavorite.objects.filter(query).select_related(
|
TrackFavorite.objects.filter(query).select_related(
|
||||||
'track',
|
"track", "user", "track__artist", "track__album__artist"
|
||||||
'user',
|
|
||||||
'track__artist',
|
|
||||||
'track__album__artist',
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
records = combined_recent(limit=limit, querysets=querysets)
|
records = combined_recent(limit=limit, querysets=querysets)
|
||||||
return [r['object'] for r in records]
|
return [r["object"] for r in records]
|
||||||
|
|
|
@ -4,8 +4,7 @@ from rest_framework.response import Response
|
||||||
from funkwhale_api.common.permissions import ConditionalAuthentication
|
from funkwhale_api.common.permissions import ConditionalAuthentication
|
||||||
from funkwhale_api.favorites.models import TrackFavorite
|
from funkwhale_api.favorites.models import TrackFavorite
|
||||||
|
|
||||||
from . import serializers
|
from . import serializers, utils
|
||||||
from . import utils
|
|
||||||
|
|
||||||
|
|
||||||
class ActivityViewSet(viewsets.GenericViewSet):
|
class ActivityViewSet(viewsets.GenericViewSet):
|
||||||
|
@ -17,4 +16,4 @@ class ActivityViewSet(viewsets.GenericViewSet):
|
||||||
def list(self, request, *args, **kwargs):
|
def list(self, request, *args, **kwargs):
|
||||||
activity = utils.get_activity(user=request.user)
|
activity = utils.get_activity(user=request.user)
|
||||||
serializer = self.serializer_class(activity, many=True)
|
serializer = self.serializer_class(activity, many=True)
|
||||||
return Response({'results': serializer.data}, status=200)
|
return Response({"results": serializer.data}, status=200)
|
||||||
|
|
|
@ -1,12 +1,7 @@
|
||||||
from urllib.parse import parse_qs
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
import jwt
|
|
||||||
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.utils.encoding import smart_text
|
|
||||||
|
|
||||||
from rest_framework import exceptions
|
from rest_framework import exceptions
|
||||||
from rest_framework_jwt.settings import api_settings
|
|
||||||
from rest_framework_jwt.authentication import BaseJSONWebTokenAuthentication
|
from rest_framework_jwt.authentication import BaseJSONWebTokenAuthentication
|
||||||
|
|
||||||
from funkwhale_api.users.models import User
|
from funkwhale_api.users.models import User
|
||||||
|
@ -16,20 +11,19 @@ class TokenHeaderAuth(BaseJSONWebTokenAuthentication):
|
||||||
def get_jwt_value(self, request):
|
def get_jwt_value(self, request):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
qs = request.get('query_string', b'').decode('utf-8')
|
qs = request.get("query_string", b"").decode("utf-8")
|
||||||
parsed = parse_qs(qs)
|
parsed = parse_qs(qs)
|
||||||
token = parsed['token'][0]
|
token = parsed["token"][0]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise exceptions.AuthenticationFailed('No token')
|
raise exceptions.AuthenticationFailed("No token")
|
||||||
|
|
||||||
if not token:
|
if not token:
|
||||||
raise exceptions.AuthenticationFailed('Empty token')
|
raise exceptions.AuthenticationFailed("Empty token")
|
||||||
|
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
class TokenAuthMiddleware:
|
class TokenAuthMiddleware:
|
||||||
|
|
||||||
def __init__(self, inner):
|
def __init__(self, inner):
|
||||||
# Store the ASGI application we were passed
|
# Store the ASGI application we were passed
|
||||||
self.inner = inner
|
self.inner = inner
|
||||||
|
@ -41,5 +35,5 @@ class TokenAuthMiddleware:
|
||||||
except (User.DoesNotExist, exceptions.AuthenticationFailed):
|
except (User.DoesNotExist, exceptions.AuthenticationFailed):
|
||||||
user = AnonymousUser()
|
user = AnonymousUser()
|
||||||
|
|
||||||
scope['user'] = user
|
scope["user"] = user
|
||||||
return self.inner(scope)
|
return self.inner(scope)
|
||||||
|
|
|
@ -1,39 +1,38 @@
|
||||||
from django.utils.encoding import smart_text
|
from django.utils.encoding import smart_text
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
from rest_framework import exceptions
|
from rest_framework import exceptions
|
||||||
from rest_framework_jwt import authentication
|
from rest_framework_jwt import authentication
|
||||||
from rest_framework_jwt.settings import api_settings
|
from rest_framework_jwt.settings import api_settings
|
||||||
|
|
||||||
|
|
||||||
class JSONWebTokenAuthenticationQS(
|
class JSONWebTokenAuthenticationQS(authentication.BaseJSONWebTokenAuthentication):
|
||||||
authentication.BaseJSONWebTokenAuthentication):
|
|
||||||
|
|
||||||
www_authenticate_realm = 'api'
|
www_authenticate_realm = "api"
|
||||||
|
|
||||||
def get_jwt_value(self, request):
|
def get_jwt_value(self, request):
|
||||||
token = request.query_params.get('jwt')
|
token = request.query_params.get("jwt")
|
||||||
if 'jwt' in request.query_params and not token:
|
if "jwt" in request.query_params and not token:
|
||||||
msg = _('Invalid Authorization header. No credentials provided.')
|
msg = _("Invalid Authorization header. No credentials provided.")
|
||||||
raise exceptions.AuthenticationFailed(msg)
|
raise exceptions.AuthenticationFailed(msg)
|
||||||
return token
|
return token
|
||||||
|
|
||||||
def authenticate_header(self, request):
|
def authenticate_header(self, request):
|
||||||
return '{0} realm="{1}"'.format(
|
return '{0} realm="{1}"'.format(
|
||||||
api_settings.JWT_AUTH_HEADER_PREFIX, self.www_authenticate_realm)
|
api_settings.JWT_AUTH_HEADER_PREFIX, self.www_authenticate_realm
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BearerTokenHeaderAuth(
|
class BearerTokenHeaderAuth(authentication.BaseJSONWebTokenAuthentication):
|
||||||
authentication.BaseJSONWebTokenAuthentication):
|
|
||||||
"""
|
"""
|
||||||
For backward compatibility purpose, we used Authorization: JWT <token>
|
For backward compatibility purpose, we used Authorization: JWT <token>
|
||||||
but Authorization: Bearer <token> is probably better.
|
but Authorization: Bearer <token> is probably better.
|
||||||
"""
|
"""
|
||||||
www_authenticate_realm = 'api'
|
|
||||||
|
www_authenticate_realm = "api"
|
||||||
|
|
||||||
def get_jwt_value(self, request):
|
def get_jwt_value(self, request):
|
||||||
auth = authentication.get_authorization_header(request).split()
|
auth = authentication.get_authorization_header(request).split()
|
||||||
auth_header_prefix = 'bearer'
|
auth_header_prefix = "bearer"
|
||||||
|
|
||||||
if not auth:
|
if not auth:
|
||||||
if api_settings.JWT_AUTH_COOKIE:
|
if api_settings.JWT_AUTH_COOKIE:
|
||||||
|
@ -44,14 +43,16 @@ class BearerTokenHeaderAuth(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if len(auth) == 1:
|
if len(auth) == 1:
|
||||||
msg = _('Invalid Authorization header. No credentials provided.')
|
msg = _("Invalid Authorization header. No credentials provided.")
|
||||||
raise exceptions.AuthenticationFailed(msg)
|
raise exceptions.AuthenticationFailed(msg)
|
||||||
elif len(auth) > 2:
|
elif len(auth) > 2:
|
||||||
msg = _('Invalid Authorization header. Credentials string '
|
msg = _(
|
||||||
'should not contain spaces.')
|
"Invalid Authorization header. Credentials string "
|
||||||
|
"should not contain spaces."
|
||||||
|
)
|
||||||
raise exceptions.AuthenticationFailed(msg)
|
raise exceptions.AuthenticationFailed(msg)
|
||||||
|
|
||||||
return auth[1]
|
return auth[1]
|
||||||
|
|
||||||
def authenticate_header(self, request):
|
def authenticate_header(self, request):
|
||||||
return '{0} realm="{1}"'.format('Bearer', self.www_authenticate_realm)
|
return '{0} realm="{1}"'.format("Bearer", self.www_authenticate_realm)
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
from channels.generic.websocket import JsonWebsocketConsumer
|
from channels.generic.websocket import JsonWebsocketConsumer
|
||||||
|
|
||||||
from funkwhale_api.common import channels
|
from funkwhale_api.common import channels
|
||||||
|
|
||||||
|
|
||||||
class JsonAuthConsumer(JsonWebsocketConsumer):
|
class JsonAuthConsumer(JsonWebsocketConsumer):
|
||||||
def connect(self):
|
def connect(self):
|
||||||
try:
|
try:
|
||||||
assert self.scope['user'].pk is not None
|
assert self.scope["user"].pk is not None
|
||||||
except (AssertionError, AttributeError, KeyError):
|
except (AssertionError, AttributeError, KeyError):
|
||||||
return self.close()
|
return self.close()
|
||||||
|
|
||||||
|
|
|
@ -3,18 +3,19 @@ from dynamic_preferences.registries import global_preferences_registry
|
||||||
|
|
||||||
from funkwhale_api.common import preferences
|
from funkwhale_api.common import preferences
|
||||||
|
|
||||||
common = types.Section('common')
|
common = types.Section("common")
|
||||||
|
|
||||||
|
|
||||||
@global_preferences_registry.register
|
@global_preferences_registry.register
|
||||||
class APIAutenticationRequired(
|
class APIAutenticationRequired(
|
||||||
preferences.DefaultFromSettingMixin, types.BooleanPreference):
|
preferences.DefaultFromSettingMixin, types.BooleanPreference
|
||||||
|
):
|
||||||
section = common
|
section = common
|
||||||
name = 'api_authentication_required'
|
name = "api_authentication_required"
|
||||||
verbose_name = 'API Requires authentication'
|
verbose_name = "API Requires authentication"
|
||||||
setting = 'API_AUTHENTICATION_REQUIRED'
|
setting = "API_AUTHENTICATION_REQUIRED"
|
||||||
help_text = (
|
help_text = (
|
||||||
'If disabled, anonymous users will be able to query the API'
|
"If disabled, anonymous users will be able to query the API"
|
||||||
'and access music data (as well as other data exposed in the API '
|
"and access music data (as well as other data exposed in the API "
|
||||||
'without specific permissions).'
|
"without specific permissions)."
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,39 +1,34 @@
|
||||||
import django_filters
|
import django_filters
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from funkwhale_api.music import utils
|
from funkwhale_api.music import utils
|
||||||
|
|
||||||
|
|
||||||
PRIVACY_LEVEL_CHOICES = [
|
PRIVACY_LEVEL_CHOICES = [
|
||||||
('me', 'Only me'),
|
("me", "Only me"),
|
||||||
('followers', 'Me and my followers'),
|
("followers", "Me and my followers"),
|
||||||
('instance', 'Everyone on my instance, and my followers'),
|
("instance", "Everyone on my instance, and my followers"),
|
||||||
('everyone', 'Everyone, including people on other instances'),
|
("everyone", "Everyone, including people on other instances"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_privacy_field():
|
def get_privacy_field():
|
||||||
return models.CharField(
|
return models.CharField(
|
||||||
max_length=30, choices=PRIVACY_LEVEL_CHOICES, default='instance')
|
max_length=30, choices=PRIVACY_LEVEL_CHOICES, default="instance"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def privacy_level_query(user, lookup_field='privacy_level'):
|
def privacy_level_query(user, lookup_field="privacy_level"):
|
||||||
if user.is_anonymous:
|
if user.is_anonymous:
|
||||||
return models.Q(**{
|
return models.Q(**{lookup_field: "everyone"})
|
||||||
lookup_field: 'everyone',
|
|
||||||
})
|
|
||||||
|
|
||||||
return models.Q(**{
|
return models.Q(
|
||||||
'{}__in'.format(lookup_field): [
|
**{"{}__in".format(lookup_field): ["followers", "instance", "everyone"]}
|
||||||
'followers', 'instance', 'everyone'
|
)
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
class SearchFilter(django_filters.CharFilter):
|
class SearchFilter(django_filters.CharFilter):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.search_fields = kwargs.pop('search_fields')
|
self.search_fields = kwargs.pop("search_fields")
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def filter(self, qs, value):
|
def filter(self, qs, value):
|
||||||
|
|
|
@ -4,17 +4,20 @@ from funkwhale_api.common import scripts
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'Run a specific script from funkwhale_api/common/scripts/'
|
help = "Run a specific script from funkwhale_api/common/scripts/"
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument('script_name', nargs='?', type=str)
|
parser.add_argument("script_name", nargs="?", type=str)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--noinput', '--no-input', action='store_false', dest='interactive',
|
"--noinput",
|
||||||
|
"--no-input",
|
||||||
|
action="store_false",
|
||||||
|
dest="interactive",
|
||||||
help="Do NOT prompt the user for input of any kind.",
|
help="Do NOT prompt the user for input of any kind.",
|
||||||
)
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
name = options['script_name']
|
name = options["script_name"]
|
||||||
if not name:
|
if not name:
|
||||||
self.show_help()
|
self.show_help()
|
||||||
|
|
||||||
|
@ -23,44 +26,43 @@ class Command(BaseCommand):
|
||||||
script = available_scripts[name]
|
script = available_scripts[name]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise CommandError(
|
raise CommandError(
|
||||||
'{} is not a valid script. Run python manage.py script for a '
|
"{} is not a valid script. Run python manage.py script for a "
|
||||||
'list of available scripts'.format(name))
|
"list of available scripts".format(name)
|
||||||
|
)
|
||||||
|
|
||||||
self.stdout.write('')
|
self.stdout.write("")
|
||||||
if options['interactive']:
|
if options["interactive"]:
|
||||||
message = (
|
message = (
|
||||||
'Are you sure you want to execute the script {}?\n\n'
|
"Are you sure you want to execute the script {}?\n\n"
|
||||||
"Type 'yes' to continue, or 'no' to cancel: "
|
"Type 'yes' to continue, or 'no' to cancel: "
|
||||||
).format(name)
|
).format(name)
|
||||||
if input(''.join(message)) != 'yes':
|
if input("".join(message)) != "yes":
|
||||||
raise CommandError("Script cancelled.")
|
raise CommandError("Script cancelled.")
|
||||||
script['entrypoint'](self, **options)
|
script["entrypoint"](self, **options)
|
||||||
|
|
||||||
def show_help(self):
|
def show_help(self):
|
||||||
indentation = 4
|
self.stdout.write("")
|
||||||
self.stdout.write('')
|
self.stdout.write("Available scripts:")
|
||||||
self.stdout.write('Available scripts:')
|
self.stdout.write("Launch with: python manage.py <script_name>")
|
||||||
self.stdout.write('Launch with: python manage.py <script_name>')
|
|
||||||
available_scripts = self.get_scripts()
|
available_scripts = self.get_scripts()
|
||||||
for name, script in sorted(available_scripts.items()):
|
for name, script in sorted(available_scripts.items()):
|
||||||
self.stdout.write('')
|
self.stdout.write("")
|
||||||
self.stdout.write(self.style.SUCCESS(name))
|
self.stdout.write(self.style.SUCCESS(name))
|
||||||
self.stdout.write('')
|
self.stdout.write("")
|
||||||
for line in script['help'].splitlines():
|
for line in script["help"].splitlines():
|
||||||
self.stdout.write(' {}'.format(line))
|
self.stdout.write(" {}".format(line))
|
||||||
self.stdout.write('')
|
self.stdout.write("")
|
||||||
|
|
||||||
def get_scripts(self):
|
def get_scripts(self):
|
||||||
available_scripts = [
|
available_scripts = [
|
||||||
k for k in sorted(scripts.__dict__.keys())
|
k for k in sorted(scripts.__dict__.keys()) if not k.startswith("__")
|
||||||
if not k.startswith('__')
|
|
||||||
]
|
]
|
||||||
data = {}
|
data = {}
|
||||||
for name in available_scripts:
|
for name in available_scripts:
|
||||||
module = getattr(scripts, name)
|
module = getattr(scripts, name)
|
||||||
data[name] = {
|
data[name] = {
|
||||||
'name': name,
|
"name": name,
|
||||||
'help': module.__doc__.strip(),
|
"help": module.__doc__.strip(),
|
||||||
'entrypoint': module.main
|
"entrypoint": module.main,
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
|
|
|
@ -7,6 +7,4 @@ class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|
||||||
operations = [
|
operations = [UnaccentExtension()]
|
||||||
UnaccentExtension()
|
|
||||||
]
|
|
||||||
|
|
|
@ -2,5 +2,5 @@ from rest_framework.pagination import PageNumberPagination
|
||||||
|
|
||||||
|
|
||||||
class FunkwhalePagination(PageNumberPagination):
|
class FunkwhalePagination(PageNumberPagination):
|
||||||
page_size_query_param = 'page_size'
|
page_size_query_param = "page_size"
|
||||||
max_page_size = 50
|
max_page_size = 50
|
||||||
|
|
|
@ -1,17 +1,14 @@
|
||||||
import operator
|
import operator
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
|
|
||||||
from rest_framework.permissions import BasePermission
|
from rest_framework.permissions import BasePermission
|
||||||
|
|
||||||
from funkwhale_api.common import preferences
|
from funkwhale_api.common import preferences
|
||||||
|
|
||||||
|
|
||||||
class ConditionalAuthentication(BasePermission):
|
class ConditionalAuthentication(BasePermission):
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
if preferences.get('common__api_authentication_required'):
|
if preferences.get("common__api_authentication_required"):
|
||||||
return request.user and request.user.is_authenticated
|
return request.user and request.user.is_authenticated
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -28,24 +25,25 @@ class OwnerPermission(BasePermission):
|
||||||
owner_field = 'owner'
|
owner_field = 'owner'
|
||||||
owner_checks = ['read', 'write']
|
owner_checks = ['read', 'write']
|
||||||
"""
|
"""
|
||||||
|
|
||||||
perms_map = {
|
perms_map = {
|
||||||
'GET': 'read',
|
"GET": "read",
|
||||||
'OPTIONS': 'read',
|
"OPTIONS": "read",
|
||||||
'HEAD': 'read',
|
"HEAD": "read",
|
||||||
'POST': 'write',
|
"POST": "write",
|
||||||
'PUT': 'write',
|
"PUT": "write",
|
||||||
'PATCH': 'write',
|
"PATCH": "write",
|
||||||
'DELETE': 'write',
|
"DELETE": "write",
|
||||||
}
|
}
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
method_check = self.perms_map[request.method]
|
method_check = self.perms_map[request.method]
|
||||||
owner_checks = getattr(view, 'owner_checks', ['read', 'write'])
|
owner_checks = getattr(view, "owner_checks", ["read", "write"])
|
||||||
if method_check not in owner_checks:
|
if method_check not in owner_checks:
|
||||||
# check not enabled
|
# check not enabled
|
||||||
return True
|
return True
|
||||||
|
|
||||||
owner_field = getattr(view, 'owner_field', 'user')
|
owner_field = getattr(view, "owner_field", "user")
|
||||||
owner = operator.attrgetter(owner_field)(obj)
|
owner = operator.attrgetter(owner_field)(obj)
|
||||||
if owner != request.user:
|
if owner != request.user:
|
||||||
raise Http404
|
raise Http404
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
from django.conf import settings
|
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
from dynamic_preferences import serializers
|
from dynamic_preferences import serializers, types
|
||||||
from dynamic_preferences import types
|
|
||||||
from dynamic_preferences.registries import global_preferences_registry
|
from dynamic_preferences.registries import global_preferences_registry
|
||||||
|
|
||||||
|
|
||||||
|
@ -17,7 +15,7 @@ def get(pref):
|
||||||
|
|
||||||
|
|
||||||
class StringListSerializer(serializers.BaseSerializer):
|
class StringListSerializer(serializers.BaseSerializer):
|
||||||
separator = ','
|
separator = ","
|
||||||
sort = True
|
sort = True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -27,8 +25,8 @@ class StringListSerializer(serializers.BaseSerializer):
|
||||||
|
|
||||||
if type(value) not in [list, tuple]:
|
if type(value) not in [list, tuple]:
|
||||||
raise cls.exception(
|
raise cls.exception(
|
||||||
"Cannot serialize, value {} is not a list or a tuple".format(
|
"Cannot serialize, value {} is not a list or a tuple".format(value)
|
||||||
value))
|
)
|
||||||
|
|
||||||
if cls.sort:
|
if cls.sort:
|
||||||
value = sorted(value)
|
value = sorted(value)
|
||||||
|
@ -38,7 +36,7 @@ class StringListSerializer(serializers.BaseSerializer):
|
||||||
def to_python(cls, value, **kwargs):
|
def to_python(cls, value, **kwargs):
|
||||||
if not value:
|
if not value:
|
||||||
return []
|
return []
|
||||||
return value.split(',')
|
return value.split(",")
|
||||||
|
|
||||||
|
|
||||||
class StringListPreference(types.BasePreferenceType):
|
class StringListPreference(types.BasePreferenceType):
|
||||||
|
@ -47,5 +45,5 @@ class StringListPreference(types.BasePreferenceType):
|
||||||
|
|
||||||
def get_api_additional_data(self):
|
def get_api_additional_data(self):
|
||||||
d = super(StringListPreference, self).get_api_additional_data()
|
d = super(StringListPreference, self).get_api_additional_data()
|
||||||
d['choices'] = self.get('choices')
|
d["choices"] = self.get("choices")
|
||||||
return d
|
return d
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
from . import django_permissions_to_user_permissions
|
|
||||||
from . import test
|
|
|
@ -2,28 +2,28 @@
|
||||||
Convert django permissions to user permissions in the database,
|
Convert django permissions to user permissions in the database,
|
||||||
following the work done in #152.
|
following the work done in #152.
|
||||||
"""
|
"""
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
from funkwhale_api.users import models
|
from funkwhale_api.users import models
|
||||||
|
|
||||||
from django.contrib.auth.models import Permission
|
|
||||||
|
|
||||||
mapping = {
|
mapping = {
|
||||||
'dynamic_preferences.change_globalpreferencemodel': 'settings',
|
"dynamic_preferences.change_globalpreferencemodel": "settings",
|
||||||
'music.add_importbatch': 'library',
|
"music.add_importbatch": "library",
|
||||||
'federation.change_library': 'federation',
|
"federation.change_library": "federation",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def main(command, **kwargs):
|
def main(command, **kwargs):
|
||||||
for codename, user_permission in sorted(mapping.items()):
|
for codename, user_permission in sorted(mapping.items()):
|
||||||
app_label, c = codename.split('.')
|
app_label, c = codename.split(".")
|
||||||
p = Permission.objects.get(
|
p = Permission.objects.get(content_type__app_label=app_label, codename=c)
|
||||||
content_type__app_label=app_label, codename=c)
|
|
||||||
users = models.User.objects.filter(
|
users = models.User.objects.filter(
|
||||||
Q(groups__permissions=p) | Q(user_permissions=p)).distinct()
|
Q(groups__permissions=p) | Q(user_permissions=p)
|
||||||
|
).distinct()
|
||||||
total = users.count()
|
total = users.count()
|
||||||
|
|
||||||
command.stdout.write('Updating {} users with {} permission...'.format(
|
command.stdout.write(
|
||||||
total, user_permission
|
"Updating {} users with {} permission...".format(total, user_permission)
|
||||||
))
|
)
|
||||||
users.update(**{'permission_{}'.format(user_permission): True})
|
users.update(**{"permission_{}".format(user_permission): True})
|
||||||
|
|
|
@ -5,4 +5,4 @@ You can launch it just to check how it works.
|
||||||
|
|
||||||
|
|
||||||
def main(command, **kwargs):
|
def main(command, **kwargs):
|
||||||
command.stdout.write('Test script run successfully')
|
command.stdout.write("Test script run successfully")
|
||||||
|
|
|
@ -17,67 +17,67 @@ class ActionSerializer(serializers.Serializer):
|
||||||
dangerous_actions = []
|
dangerous_actions = []
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.queryset = kwargs.pop('queryset')
|
self.queryset = kwargs.pop("queryset")
|
||||||
if self.actions is None:
|
if self.actions is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
'You must declare a list of actions on '
|
"You must declare a list of actions on " "the serializer class"
|
||||||
'the serializer class')
|
)
|
||||||
|
|
||||||
for action in self.actions:
|
for action in self.actions:
|
||||||
handler_name = 'handle_{}'.format(action)
|
handler_name = "handle_{}".format(action)
|
||||||
assert hasattr(self, handler_name), (
|
assert hasattr(self, handler_name), "{} miss a {} method".format(
|
||||||
'{} miss a {} method'.format(
|
self.__class__.__name__, handler_name
|
||||||
self.__class__.__name__, handler_name)
|
|
||||||
)
|
)
|
||||||
super().__init__(self, *args, **kwargs)
|
super().__init__(self, *args, **kwargs)
|
||||||
|
|
||||||
def validate_action(self, value):
|
def validate_action(self, value):
|
||||||
if value not in self.actions:
|
if value not in self.actions:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
'{} is not a valid action. Pick one of {}.'.format(
|
"{} is not a valid action. Pick one of {}.".format(
|
||||||
value, ', '.join(self.actions)
|
value, ", ".join(self.actions)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def validate_objects(self, value):
|
def validate_objects(self, value):
|
||||||
qs = None
|
if value == "all":
|
||||||
if value == 'all':
|
return self.queryset.all().order_by("id")
|
||||||
return self.queryset.all().order_by('id')
|
|
||||||
if type(value) in [list, tuple]:
|
if type(value) in [list, tuple]:
|
||||||
return self.queryset.filter(pk__in=value).order_by('id')
|
return self.queryset.filter(pk__in=value).order_by("id")
|
||||||
|
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
'{} is not a valid value for objects. You must provide either a '
|
"{} is not a valid value for objects. You must provide either a "
|
||||||
'list of identifiers or the string "all".'.format(value))
|
'list of identifiers or the string "all".'.format(value)
|
||||||
|
)
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
dangerous = data['action'] in self.dangerous_actions
|
dangerous = data["action"] in self.dangerous_actions
|
||||||
if dangerous and self.initial_data['objects'] == 'all':
|
if dangerous and self.initial_data["objects"] == "all":
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
'This action is to dangerous to be applied to all objects')
|
"This action is to dangerous to be applied to all objects"
|
||||||
if self.filterset_class and 'filters' in data:
|
)
|
||||||
|
if self.filterset_class and "filters" in data:
|
||||||
qs_filterset = self.filterset_class(
|
qs_filterset = self.filterset_class(
|
||||||
data['filters'], queryset=data['objects'])
|
data["filters"], queryset=data["objects"]
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
assert qs_filterset.form.is_valid()
|
assert qs_filterset.form.is_valid()
|
||||||
except (AssertionError, TypeError):
|
except (AssertionError, TypeError):
|
||||||
raise serializers.ValidationError('Invalid filters')
|
raise serializers.ValidationError("Invalid filters")
|
||||||
data['objects'] = qs_filterset.qs
|
data["objects"] = qs_filterset.qs
|
||||||
|
|
||||||
data['count'] = data['objects'].count()
|
data["count"] = data["objects"].count()
|
||||||
if data['count'] < 1:
|
if data["count"] < 1:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError("No object matching your request")
|
||||||
'No object matching your request')
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
handler_name = 'handle_{}'.format(self.validated_data['action'])
|
handler_name = "handle_{}".format(self.validated_data["action"])
|
||||||
handler = getattr(self, handler_name)
|
handler = getattr(self, handler_name)
|
||||||
result = handler(self.validated_data['objects'])
|
result = handler(self.validated_data["objects"])
|
||||||
payload = {
|
payload = {
|
||||||
'updated': self.validated_data['count'],
|
"updated": self.validated_data["count"],
|
||||||
'action': self.validated_data['action'],
|
"action": self.validated_data["action"],
|
||||||
'result': result,
|
"result": result,
|
||||||
}
|
}
|
||||||
return payload
|
return payload
|
||||||
|
|
|
@ -1,18 +1,16 @@
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
import funkwhale_api
|
import funkwhale_api
|
||||||
|
|
||||||
|
|
||||||
def get_user_agent():
|
def get_user_agent():
|
||||||
return 'python-requests (funkwhale/{}; +{})'.format(
|
return "python-requests (funkwhale/{}; +{})".format(
|
||||||
funkwhale_api.__version__,
|
funkwhale_api.__version__, settings.FUNKWHALE_URL
|
||||||
settings.FUNKWHALE_URL
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_session():
|
def get_session():
|
||||||
s = requests.Session()
|
s = requests.Session()
|
||||||
s.headers['User-Agent'] = get_user_agent()
|
s.headers["User-Agent"] = get_user_agent()
|
||||||
return s
|
return s
|
||||||
|
|
|
@ -7,6 +7,7 @@ class ASCIIFileSystemStorage(FileSystemStorage):
|
||||||
"""
|
"""
|
||||||
Convert unicode characters in name to ASCII characters.
|
Convert unicode characters in name to ASCII characters.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_valid_name(self, name):
|
def get_valid_name(self, name):
|
||||||
name = unicodedata.normalize('NFKD', name).encode('ascii', 'ignore')
|
name = unicodedata.normalize("NFKD", name).encode("ascii", "ignore")
|
||||||
return super().get_valid_name(name)
|
return super().get_valid_name(name)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
from urllib.parse import urlencode, parse_qs, urlsplit, urlunsplit
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
|
@ -9,13 +9,13 @@ def rename_file(instance, field_name, new_name, allow_missing_file=False):
|
||||||
field = getattr(instance, field_name)
|
field = getattr(instance, field_name)
|
||||||
current_name, extension = os.path.splitext(field.name)
|
current_name, extension = os.path.splitext(field.name)
|
||||||
|
|
||||||
new_name_with_extension = '{}{}'.format(new_name, extension)
|
new_name_with_extension = "{}{}".format(new_name, extension)
|
||||||
try:
|
try:
|
||||||
shutil.move(field.path, new_name_with_extension)
|
shutil.move(field.path, new_name_with_extension)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
if not allow_missing_file:
|
if not allow_missing_file:
|
||||||
raise
|
raise
|
||||||
print('Skipped missing file', field.path)
|
print("Skipped missing file", field.path)
|
||||||
initial_path = os.path.dirname(field.name)
|
initial_path = os.path.dirname(field.name)
|
||||||
field.name = os.path.join(initial_path, new_name_with_extension)
|
field.name = os.path.join(initial_path, new_name_with_extension)
|
||||||
instance.save()
|
instance.save()
|
||||||
|
@ -23,9 +23,7 @@ def rename_file(instance, field_name, new_name, allow_missing_file=False):
|
||||||
|
|
||||||
|
|
||||||
def on_commit(f, *args, **kwargs):
|
def on_commit(f, *args, **kwargs):
|
||||||
return transaction.on_commit(
|
return transaction.on_commit(lambda: f(*args, **kwargs))
|
||||||
lambda: f(*args, **kwargs)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def set_query_parameter(url, **kwargs):
|
def set_query_parameter(url, **kwargs):
|
||||||
|
|
|
@ -7,25 +7,39 @@ import django.contrib.sites.models
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = []
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Site',
|
name="Site",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)),
|
(
|
||||||
('domain', models.CharField(verbose_name='domain name', max_length=100, validators=[django.contrib.sites.models._simple_domain_name_validator])),
|
"id",
|
||||||
('name', models.CharField(verbose_name='display name', max_length=50)),
|
models.AutoField(
|
||||||
],
|
verbose_name="ID",
|
||||||
options={
|
primary_key=True,
|
||||||
'verbose_name_plural': 'sites',
|
serialize=False,
|
||||||
'verbose_name': 'site',
|
auto_created=True,
|
||||||
'db_table': 'django_site',
|
),
|
||||||
'ordering': ('domain',),
|
),
|
||||||
},
|
(
|
||||||
managers=[
|
"domain",
|
||||||
('objects', django.contrib.sites.models.SiteManager()),
|
models.CharField(
|
||||||
|
verbose_name="domain name",
|
||||||
|
max_length=100,
|
||||||
|
validators=[
|
||||||
|
django.contrib.sites.models._simple_domain_name_validator
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(verbose_name="display name", max_length=50)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name_plural": "sites",
|
||||||
|
"verbose_name": "site",
|
||||||
|
"db_table": "django_site",
|
||||||
|
"ordering": ("domain",),
|
||||||
|
},
|
||||||
|
managers=[("objects", django.contrib.sites.models.SiteManager())],
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
|
@ -10,10 +10,7 @@ def update_site_forward(apps, schema_editor):
|
||||||
Site = apps.get_model("sites", "Site")
|
Site = apps.get_model("sites", "Site")
|
||||||
Site.objects.update_or_create(
|
Site.objects.update_or_create(
|
||||||
id=settings.SITE_ID,
|
id=settings.SITE_ID,
|
||||||
defaults={
|
defaults={"domain": "funkwhale.io", "name": "funkwhale_api"},
|
||||||
"domain": "funkwhale.io",
|
|
||||||
"name": "funkwhale_api"
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -21,20 +18,12 @@ def update_site_backward(apps, schema_editor):
|
||||||
"""Revert site domain and name to default."""
|
"""Revert site domain and name to default."""
|
||||||
Site = apps.get_model("sites", "Site")
|
Site = apps.get_model("sites", "Site")
|
||||||
Site.objects.update_or_create(
|
Site.objects.update_or_create(
|
||||||
id=settings.SITE_ID,
|
id=settings.SITE_ID, defaults={"domain": "example.com", "name": "example.com"}
|
||||||
defaults={
|
|
||||||
"domain": "example.com",
|
|
||||||
"name": "example.com"
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("sites", "0001_initial")]
|
||||||
('sites', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [migrations.RunPython(update_site_forward, update_site_backward)]
|
||||||
migrations.RunPython(update_site_forward, update_site_backward),
|
|
||||||
]
|
|
||||||
|
|
|
@ -8,20 +8,21 @@ from django.db import migrations, models
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("sites", "0002_set_site_domain_and_name")]
|
||||||
('sites', '0002_set_site_domain_and_name'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterModelManagers(
|
migrations.AlterModelManagers(
|
||||||
name='site',
|
name="site",
|
||||||
managers=[
|
managers=[("objects", django.contrib.sites.models.SiteManager())],
|
||||||
('objects', django.contrib.sites.models.SiteManager()),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='site',
|
model_name="site",
|
||||||
name='domain',
|
name="domain",
|
||||||
field=models.CharField(max_length=100, unique=True, validators=[django.contrib.sites.models._simple_domain_name_validator], verbose_name='domain name'),
|
field=models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
unique=True,
|
||||||
|
validators=[django.contrib.sites.models._simple_domain_name_validator],
|
||||||
|
verbose_name="domain name",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
|
|
||||||
from .downloader import download
|
from .downloader import download
|
||||||
|
|
||||||
|
__all__ = ["download"]
|
||||||
|
|
|
@ -1,26 +1,19 @@
|
||||||
import os
|
import os
|
||||||
import json
|
|
||||||
from urllib.parse import quote_plus
|
|
||||||
import youtube_dl
|
import youtube_dl
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
import glob
|
|
||||||
|
|
||||||
|
|
||||||
def download(
|
def download(
|
||||||
url,
|
url, target_directory=settings.MEDIA_ROOT, name="%(id)s.%(ext)s", bitrate=192
|
||||||
target_directory=settings.MEDIA_ROOT,
|
):
|
||||||
name="%(id)s.%(ext)s",
|
|
||||||
bitrate=192):
|
|
||||||
target_path = os.path.join(target_directory, name)
|
target_path = os.path.join(target_directory, name)
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
'quiet': True,
|
"quiet": True,
|
||||||
'outtmpl': target_path,
|
"outtmpl": target_path,
|
||||||
'postprocessors': [{
|
"postprocessors": [{"key": "FFmpegExtractAudio", "preferredcodec": "vorbis"}],
|
||||||
'key': 'FFmpegExtractAudio',
|
|
||||||
'preferredcodec': 'vorbis',
|
|
||||||
}],
|
|
||||||
}
|
}
|
||||||
_downloader = youtube_dl.YoutubeDL(ydl_opts)
|
_downloader = youtube_dl.YoutubeDL(ydl_opts)
|
||||||
info = _downloader.extract_info(url)
|
info = _downloader.extract_info(url)
|
||||||
info['audio_file_path'] = target_path % {'id': info['id'], 'ext': 'ogg'}
|
info["audio_file_path"] = target_path % {"id": info["id"], "ext": "ogg"}
|
||||||
return info
|
return info
|
||||||
|
|
|
@ -3,7 +3,7 @@ import persisting_theory
|
||||||
|
|
||||||
|
|
||||||
class FactoriesRegistry(persisting_theory.Registry):
|
class FactoriesRegistry(persisting_theory.Registry):
|
||||||
look_into = 'factories'
|
look_into = "factories"
|
||||||
|
|
||||||
def prepare_name(self, data, name=None):
|
def prepare_name(self, data, name=None):
|
||||||
return name or data._meta.model._meta.label
|
return name or data._meta.model._meta.label
|
||||||
|
|
|
@ -1,19 +1,16 @@
|
||||||
from funkwhale_api.common import channels
|
|
||||||
from funkwhale_api.activity import record
|
from funkwhale_api.activity import record
|
||||||
|
from funkwhale_api.common import channels
|
||||||
|
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
|
||||||
record.registry.register_serializer(
|
record.registry.register_serializer(serializers.TrackFavoriteActivitySerializer)
|
||||||
serializers.TrackFavoriteActivitySerializer)
|
|
||||||
|
|
||||||
|
|
||||||
@record.registry.register_consumer('favorites.TrackFavorite')
|
@record.registry.register_consumer("favorites.TrackFavorite")
|
||||||
def broadcast_track_favorite_to_instance_activity(data, obj):
|
def broadcast_track_favorite_to_instance_activity(data, obj):
|
||||||
if obj.user.privacy_level not in ['instance', 'everyone']:
|
if obj.user.privacy_level not in ["instance", "everyone"]:
|
||||||
return
|
return
|
||||||
|
|
||||||
channels.group_send('instance_activity', {
|
channels.group_send(
|
||||||
'type': 'event.send',
|
"instance_activity", {"type": "event.send", "text": "", "data": data}
|
||||||
'text': '',
|
)
|
||||||
'data': data
|
|
||||||
})
|
|
||||||
|
|
|
@ -5,8 +5,5 @@ from . import models
|
||||||
|
|
||||||
@admin.register(models.TrackFavorite)
|
@admin.register(models.TrackFavorite)
|
||||||
class TrackFavoriteAdmin(admin.ModelAdmin):
|
class TrackFavoriteAdmin(admin.ModelAdmin):
|
||||||
list_display = ['user', 'track', 'creation_date']
|
list_display = ["user", "track", "creation_date"]
|
||||||
list_select_related = [
|
list_select_related = ["user", "track"]
|
||||||
'user',
|
|
||||||
'track'
|
|
||||||
]
|
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import factory
|
import factory
|
||||||
|
|
||||||
from funkwhale_api.factories import registry
|
from funkwhale_api.factories import registry
|
||||||
|
|
||||||
from funkwhale_api.music.factories import TrackFactory
|
from funkwhale_api.music.factories import TrackFactory
|
||||||
from funkwhale_api.users.factories import UserFactory
|
from funkwhale_api.users.factories import UserFactory
|
||||||
|
|
||||||
|
@ -12,4 +11,4 @@ class TrackFavorite(factory.django.DjangoModelFactory):
|
||||||
user = factory.SubFactory(UserFactory)
|
user = factory.SubFactory(UserFactory)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = 'favorites.TrackFavorite'
|
model = "favorites.TrackFavorite"
|
||||||
|
|
|
@ -9,25 +9,47 @@ from django.conf import settings
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('music', '0003_auto_20151222_2233'),
|
("music", "0003_auto_20151222_2233"),
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='TrackFavorite',
|
name="TrackFavorite",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(serialize=False, auto_created=True, verbose_name='ID', primary_key=True)),
|
(
|
||||||
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
|
"id",
|
||||||
('track', models.ForeignKey(related_name='track_favorites', to='music.Track', on_delete=models.CASCADE)),
|
models.AutoField(
|
||||||
('user', models.ForeignKey(related_name='track_favorites', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
|
serialize=False,
|
||||||
|
auto_created=True,
|
||||||
|
verbose_name="ID",
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"creation_date",
|
||||||
|
models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"track",
|
||||||
|
models.ForeignKey(
|
||||||
|
related_name="track_favorites",
|
||||||
|
to="music.Track",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
related_name="track_favorites",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={"ordering": ("-creation_date",)},
|
||||||
'ordering': ('-creation_date',),
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
migrations.AlterUniqueTogether(
|
migrations.AlterUniqueTogether(
|
||||||
name='trackfavorite',
|
name="trackfavorite", unique_together=set([("track", "user")])
|
||||||
unique_together=set([('track', 'user')]),
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
from django.conf import settings
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
@ -8,13 +7,15 @@ from funkwhale_api.music.models import Track
|
||||||
class TrackFavorite(models.Model):
|
class TrackFavorite(models.Model):
|
||||||
creation_date = models.DateTimeField(default=timezone.now)
|
creation_date = models.DateTimeField(default=timezone.now)
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
'users.User', related_name='track_favorites', on_delete=models.CASCADE)
|
"users.User", related_name="track_favorites", on_delete=models.CASCADE
|
||||||
|
)
|
||||||
track = models.ForeignKey(
|
track = models.ForeignKey(
|
||||||
Track, related_name='track_favorites', on_delete=models.CASCADE)
|
Track, related_name="track_favorites", on_delete=models.CASCADE
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ('track', 'user')
|
unique_together = ("track", "user")
|
||||||
ordering = ('-creation_date',)
|
ordering = ("-creation_date",)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def add(cls, track, user):
|
def add(cls, track, user):
|
||||||
|
@ -22,5 +23,4 @@ class TrackFavorite(models.Model):
|
||||||
return favorite
|
return favorite
|
||||||
|
|
||||||
def get_activity_url(self):
|
def get_activity_url(self):
|
||||||
return '{}/favorites/tracks/{}'.format(
|
return "{}/favorites/tracks/{}".format(self.user.get_activity_url(), self.pk)
|
||||||
self.user.get_activity_url(), self.pk)
|
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
@ -11,29 +10,22 @@ from . import models
|
||||||
|
|
||||||
class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer):
|
class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer):
|
||||||
type = serializers.SerializerMethodField()
|
type = serializers.SerializerMethodField()
|
||||||
object = TrackActivitySerializer(source='track')
|
object = TrackActivitySerializer(source="track")
|
||||||
actor = UserActivitySerializer(source='user')
|
actor = UserActivitySerializer(source="user")
|
||||||
published = serializers.DateTimeField(source='creation_date')
|
published = serializers.DateTimeField(source="creation_date")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.TrackFavorite
|
model = models.TrackFavorite
|
||||||
fields = [
|
fields = ["id", "local_id", "object", "type", "actor", "published"]
|
||||||
'id',
|
|
||||||
'local_id',
|
|
||||||
'object',
|
|
||||||
'type',
|
|
||||||
'actor',
|
|
||||||
'published'
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_actor(self, obj):
|
def get_actor(self, obj):
|
||||||
return UserActivitySerializer(obj.user).data
|
return UserActivitySerializer(obj.user).data
|
||||||
|
|
||||||
def get_type(self, obj):
|
def get_type(self, obj):
|
||||||
return 'Like'
|
return "Like"
|
||||||
|
|
||||||
|
|
||||||
class UserTrackFavoriteSerializer(serializers.ModelSerializer):
|
class UserTrackFavoriteSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.TrackFavorite
|
model = models.TrackFavorite
|
||||||
fields = ('id', 'track', 'creation_date')
|
fields = ("id", "track", "creation_date")
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
from django.conf.urls import include, url
|
from rest_framework import routers
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
from rest_framework import routers
|
|
||||||
router = routers.SimpleRouter()
|
router = routers.SimpleRouter()
|
||||||
router.register(r'tracks', views.TrackFavoriteViewSet, 'tracks')
|
router.register(r"tracks", views.TrackFavoriteViewSet, "tracks")
|
||||||
|
|
||||||
urlpatterns = router.urls
|
urlpatterns = router.urls
|
||||||
|
|
|
@ -1,24 +1,23 @@
|
||||||
from rest_framework import generics, mixins, viewsets
|
from rest_framework import mixins, status, viewsets
|
||||||
from rest_framework import status
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework import pagination
|
|
||||||
from rest_framework.decorators import list_route
|
from rest_framework.decorators import list_route
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from funkwhale_api.activity import record
|
from funkwhale_api.activity import record
|
||||||
from funkwhale_api.music.models import Track
|
|
||||||
from funkwhale_api.common.permissions import ConditionalAuthentication
|
from funkwhale_api.common.permissions import ConditionalAuthentication
|
||||||
|
from funkwhale_api.music.models import Track
|
||||||
|
|
||||||
from . import models
|
from . import models, serializers
|
||||||
from . import serializers
|
|
||||||
|
|
||||||
|
|
||||||
class TrackFavoriteViewSet(mixins.CreateModelMixin,
|
class TrackFavoriteViewSet(
|
||||||
|
mixins.CreateModelMixin,
|
||||||
mixins.DestroyModelMixin,
|
mixins.DestroyModelMixin,
|
||||||
mixins.ListModelMixin,
|
mixins.ListModelMixin,
|
||||||
viewsets.GenericViewSet):
|
viewsets.GenericViewSet,
|
||||||
|
):
|
||||||
|
|
||||||
serializer_class = serializers.UserTrackFavoriteSerializer
|
serializer_class = serializers.UserTrackFavoriteSerializer
|
||||||
queryset = (models.TrackFavorite.objects.all())
|
queryset = models.TrackFavorite.objects.all()
|
||||||
permission_classes = [ConditionalAuthentication]
|
permission_classes = [ConditionalAuthentication]
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
|
@ -28,20 +27,22 @@ class TrackFavoriteViewSet(mixins.CreateModelMixin,
|
||||||
serializer = self.get_serializer(instance=instance)
|
serializer = self.get_serializer(instance=instance)
|
||||||
headers = self.get_success_headers(serializer.data)
|
headers = self.get_success_headers(serializer.data)
|
||||||
record.send(instance)
|
record.send(instance)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
return Response(
|
||||||
|
serializer.data, status=status.HTTP_201_CREATED, headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.queryset.filter(user=self.request.user)
|
return self.queryset.filter(user=self.request.user)
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
track = Track.objects.get(pk=serializer.data['track'])
|
track = Track.objects.get(pk=serializer.data["track"])
|
||||||
favorite = models.TrackFavorite.add(track=track, user=self.request.user)
|
favorite = models.TrackFavorite.add(track=track, user=self.request.user)
|
||||||
return favorite
|
return favorite
|
||||||
|
|
||||||
@list_route(methods=['delete', 'post'])
|
@list_route(methods=["delete", "post"])
|
||||||
def remove(self, request, *args, **kwargs):
|
def remove(self, request, *args, **kwargs):
|
||||||
try:
|
try:
|
||||||
pk = int(request.data['track'])
|
pk = int(request.data["track"])
|
||||||
favorite = request.user.track_favorites.get(track__pk=pk)
|
favorite = request.user.track_favorites.get(track__pk=pk)
|
||||||
except (AttributeError, ValueError, models.TrackFavorite.DoesNotExist):
|
except (AttributeError, ValueError, models.TrackFavorite.DoesNotExist):
|
||||||
return Response({}, status=400)
|
return Response({}, status=400)
|
||||||
|
|
|
@ -1,67 +1,61 @@
|
||||||
from . import serializers
|
|
||||||
from . import tasks
|
|
||||||
|
|
||||||
ACTIVITY_TYPES = [
|
ACTIVITY_TYPES = [
|
||||||
'Accept',
|
"Accept",
|
||||||
'Add',
|
"Add",
|
||||||
'Announce',
|
"Announce",
|
||||||
'Arrive',
|
"Arrive",
|
||||||
'Block',
|
"Block",
|
||||||
'Create',
|
"Create",
|
||||||
'Delete',
|
"Delete",
|
||||||
'Dislike',
|
"Dislike",
|
||||||
'Flag',
|
"Flag",
|
||||||
'Follow',
|
"Follow",
|
||||||
'Ignore',
|
"Ignore",
|
||||||
'Invite',
|
"Invite",
|
||||||
'Join',
|
"Join",
|
||||||
'Leave',
|
"Leave",
|
||||||
'Like',
|
"Like",
|
||||||
'Listen',
|
"Listen",
|
||||||
'Move',
|
"Move",
|
||||||
'Offer',
|
"Offer",
|
||||||
'Question',
|
"Question",
|
||||||
'Reject',
|
"Reject",
|
||||||
'Read',
|
"Read",
|
||||||
'Remove',
|
"Remove",
|
||||||
'TentativeReject',
|
"TentativeReject",
|
||||||
'TentativeAccept',
|
"TentativeAccept",
|
||||||
'Travel',
|
"Travel",
|
||||||
'Undo',
|
"Undo",
|
||||||
'Update',
|
"Update",
|
||||||
'View',
|
"View",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
OBJECT_TYPES = [
|
OBJECT_TYPES = [
|
||||||
'Article',
|
"Article",
|
||||||
'Audio',
|
"Audio",
|
||||||
'Collection',
|
"Collection",
|
||||||
'Document',
|
"Document",
|
||||||
'Event',
|
"Event",
|
||||||
'Image',
|
"Image",
|
||||||
'Note',
|
"Note",
|
||||||
'OrderedCollection',
|
"OrderedCollection",
|
||||||
'Page',
|
"Page",
|
||||||
'Place',
|
"Place",
|
||||||
'Profile',
|
"Profile",
|
||||||
'Relationship',
|
"Relationship",
|
||||||
'Tombstone',
|
"Tombstone",
|
||||||
'Video',
|
"Video",
|
||||||
] + ACTIVITY_TYPES
|
] + ACTIVITY_TYPES
|
||||||
|
|
||||||
|
|
||||||
def deliver(activity, on_behalf_of, to=[]):
|
def deliver(activity, on_behalf_of, to=[]):
|
||||||
return tasks.send.delay(
|
from . import tasks
|
||||||
activity=activity,
|
|
||||||
actor_id=on_behalf_of.pk,
|
return tasks.send.delay(activity=activity, actor_id=on_behalf_of.pk, to=to)
|
||||||
to=to
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def accept_follow(follow):
|
def accept_follow(follow):
|
||||||
|
from . import serializers
|
||||||
|
|
||||||
serializer = serializers.AcceptFollowSerializer(follow)
|
serializer = serializers.AcceptFollowSerializer(follow)
|
||||||
return deliver(
|
return deliver(serializer.data, to=[follow.actor.url], on_behalf_of=follow.target)
|
||||||
serializer.data,
|
|
||||||
to=[follow.actor.url],
|
|
||||||
on_behalf_of=follow.target)
|
|
||||||
|
|
|
@ -1,36 +1,28 @@
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
|
||||||
import xml
|
import xml
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
|
||||||
from dynamic_preferences.registries import global_preferences_registry
|
from funkwhale_api.common import preferences, session
|
||||||
|
|
||||||
from funkwhale_api.common import preferences
|
|
||||||
from funkwhale_api.common import session
|
|
||||||
from funkwhale_api.common import utils as funkwhale_utils
|
from funkwhale_api.common import utils as funkwhale_utils
|
||||||
from funkwhale_api.music import models as music_models
|
from funkwhale_api.music import models as music_models
|
||||||
from funkwhale_api.music import tasks as music_tasks
|
from funkwhale_api.music import tasks as music_tasks
|
||||||
|
|
||||||
from . import activity
|
from . import activity, keys, models, serializers, signing, utils
|
||||||
from . import keys
|
|
||||||
from . import models
|
|
||||||
from . import serializers
|
|
||||||
from . import signing
|
|
||||||
from . import utils
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def remove_tags(text):
|
def remove_tags(text):
|
||||||
logger.debug('Removing tags from %s', text)
|
logger.debug("Removing tags from %s", text)
|
||||||
return ''.join(xml.etree.ElementTree.fromstring('<div>{}</div>'.format(text)).itertext())
|
return "".join(
|
||||||
|
xml.etree.ElementTree.fromstring("<div>{}</div>".format(text)).itertext()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_actor_data(actor_url):
|
def get_actor_data(actor_url):
|
||||||
|
@ -38,16 +30,13 @@ def get_actor_data(actor_url):
|
||||||
actor_url,
|
actor_url,
|
||||||
timeout=5,
|
timeout=5,
|
||||||
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
|
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
|
||||||
headers={
|
headers={"Accept": "application/activity+json"},
|
||||||
'Accept': 'application/activity+json',
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
try:
|
try:
|
||||||
return response.json()
|
return response.json()
|
||||||
except:
|
except Exception:
|
||||||
raise ValueError(
|
raise ValueError("Invalid actor payload: {}".format(response.text))
|
||||||
'Invalid actor payload: {}'.format(response.text))
|
|
||||||
|
|
||||||
|
|
||||||
def get_actor(actor_url):
|
def get_actor(actor_url):
|
||||||
|
@ -56,7 +45,8 @@ def get_actor(actor_url):
|
||||||
except models.Actor.DoesNotExist:
|
except models.Actor.DoesNotExist:
|
||||||
actor = None
|
actor = None
|
||||||
fetch_delta = datetime.timedelta(
|
fetch_delta = datetime.timedelta(
|
||||||
minutes=preferences.get('federation__actor_fetch_delay'))
|
minutes=preferences.get("federation__actor_fetch_delay")
|
||||||
|
)
|
||||||
if actor and actor.last_fetch_date > timezone.now() - fetch_delta:
|
if actor and actor.last_fetch_date > timezone.now() - fetch_delta:
|
||||||
# cache is hot, we can return as is
|
# cache is hot, we can return as is
|
||||||
return actor
|
return actor
|
||||||
|
@ -73,8 +63,7 @@ class SystemActor(object):
|
||||||
|
|
||||||
def get_request_auth(self):
|
def get_request_auth(self):
|
||||||
actor = self.get_actor_instance()
|
actor = self.get_actor_instance()
|
||||||
return signing.get_auth(
|
return signing.get_auth(actor.private_key, actor.private_key_id)
|
||||||
actor.private_key, actor.private_key_id)
|
|
||||||
|
|
||||||
def serialize(self):
|
def serialize(self):
|
||||||
actor = self.get_actor_instance()
|
actor = self.get_actor_instance()
|
||||||
|
@ -88,42 +77,35 @@ class SystemActor(object):
|
||||||
pass
|
pass
|
||||||
private, public = keys.get_key_pair()
|
private, public = keys.get_key_pair()
|
||||||
args = self.get_instance_argument(
|
args = self.get_instance_argument(
|
||||||
self.id,
|
self.id, name=self.name, summary=self.summary, **self.additional_attributes
|
||||||
name=self.name,
|
|
||||||
summary=self.summary,
|
|
||||||
**self.additional_attributes
|
|
||||||
)
|
)
|
||||||
args['private_key'] = private.decode('utf-8')
|
args["private_key"] = private.decode("utf-8")
|
||||||
args['public_key'] = public.decode('utf-8')
|
args["public_key"] = public.decode("utf-8")
|
||||||
return models.Actor.objects.create(**args)
|
return models.Actor.objects.create(**args)
|
||||||
|
|
||||||
def get_actor_url(self):
|
def get_actor_url(self):
|
||||||
return utils.full_url(
|
return utils.full_url(
|
||||||
reverse(
|
reverse("federation:instance-actors-detail", kwargs={"actor": self.id})
|
||||||
'federation:instance-actors-detail',
|
)
|
||||||
kwargs={'actor': self.id}))
|
|
||||||
|
|
||||||
def get_instance_argument(self, id, name, summary, **kwargs):
|
def get_instance_argument(self, id, name, summary, **kwargs):
|
||||||
p = {
|
p = {
|
||||||
'preferred_username': id,
|
"preferred_username": id,
|
||||||
'domain': settings.FEDERATION_HOSTNAME,
|
"domain": settings.FEDERATION_HOSTNAME,
|
||||||
'type': 'Person',
|
"type": "Person",
|
||||||
'name': name.format(host=settings.FEDERATION_HOSTNAME),
|
"name": name.format(host=settings.FEDERATION_HOSTNAME),
|
||||||
'manually_approves_followers': True,
|
"manually_approves_followers": True,
|
||||||
'url': self.get_actor_url(),
|
"url": self.get_actor_url(),
|
||||||
'shared_inbox_url': utils.full_url(
|
"shared_inbox_url": utils.full_url(
|
||||||
reverse(
|
reverse("federation:instance-actors-inbox", kwargs={"actor": id})
|
||||||
'federation:instance-actors-inbox',
|
),
|
||||||
kwargs={'actor': id})),
|
"inbox_url": utils.full_url(
|
||||||
'inbox_url': utils.full_url(
|
reverse("federation:instance-actors-inbox", kwargs={"actor": id})
|
||||||
reverse(
|
),
|
||||||
'federation:instance-actors-inbox',
|
"outbox_url": utils.full_url(
|
||||||
kwargs={'actor': id})),
|
reverse("federation:instance-actors-outbox", kwargs={"actor": id})
|
||||||
'outbox_url': utils.full_url(
|
),
|
||||||
reverse(
|
"summary": summary.format(host=settings.FEDERATION_HOSTNAME),
|
||||||
'federation:instance-actors-outbox',
|
|
||||||
kwargs={'actor': id})),
|
|
||||||
'summary': summary.format(host=settings.FEDERATION_HOSTNAME)
|
|
||||||
}
|
}
|
||||||
p.update(kwargs)
|
p.update(kwargs)
|
||||||
return p
|
return p
|
||||||
|
@ -145,32 +127,29 @@ class SystemActor(object):
|
||||||
Main entrypoint for handling activities posted to the
|
Main entrypoint for handling activities posted to the
|
||||||
actor's inbox
|
actor's inbox
|
||||||
"""
|
"""
|
||||||
logger.info('Received activity on %s inbox', self.id)
|
logger.info("Received activity on %s inbox", self.id)
|
||||||
|
|
||||||
if actor is None:
|
if actor is None:
|
||||||
raise PermissionDenied('Actor not authenticated')
|
raise PermissionDenied("Actor not authenticated")
|
||||||
|
|
||||||
serializer = serializers.ActivitySerializer(
|
serializer = serializers.ActivitySerializer(data=data, context={"actor": actor})
|
||||||
data=data, context={'actor': actor})
|
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
ac = serializer.data
|
ac = serializer.data
|
||||||
try:
|
try:
|
||||||
handler = getattr(
|
handler = getattr(self, "handle_{}".format(ac["type"].lower()))
|
||||||
self, 'handle_{}'.format(ac['type'].lower()))
|
|
||||||
except (KeyError, AttributeError):
|
except (KeyError, AttributeError):
|
||||||
logger.debug(
|
logger.debug("No handler for activity %s", ac["type"])
|
||||||
'No handler for activity %s', ac['type'])
|
|
||||||
return
|
return
|
||||||
|
|
||||||
return handler(data, actor)
|
return handler(data, actor)
|
||||||
|
|
||||||
def handle_follow(self, ac, sender):
|
def handle_follow(self, ac, sender):
|
||||||
system_actor = self.get_actor_instance()
|
|
||||||
serializer = serializers.FollowSerializer(
|
serializer = serializers.FollowSerializer(
|
||||||
data=ac, context={'follow_actor': sender})
|
data=ac, context={"follow_actor": sender}
|
||||||
|
)
|
||||||
if not serializer.is_valid():
|
if not serializer.is_valid():
|
||||||
return logger.info('Invalid follow payload')
|
return logger.info("Invalid follow payload")
|
||||||
approved = True if not self.manually_approves_followers else None
|
approved = True if not self.manually_approves_followers else None
|
||||||
follow = serializer.save(approved=approved)
|
follow = serializer.save(approved=approved)
|
||||||
if follow.approved:
|
if follow.approved:
|
||||||
|
@ -179,26 +158,27 @@ class SystemActor(object):
|
||||||
def handle_accept(self, ac, sender):
|
def handle_accept(self, ac, sender):
|
||||||
system_actor = self.get_actor_instance()
|
system_actor = self.get_actor_instance()
|
||||||
serializer = serializers.AcceptFollowSerializer(
|
serializer = serializers.AcceptFollowSerializer(
|
||||||
data=ac,
|
data=ac, context={"follow_target": sender, "follow_actor": system_actor}
|
||||||
context={'follow_target': sender, 'follow_actor': system_actor})
|
)
|
||||||
if not serializer.is_valid(raise_exception=True):
|
if not serializer.is_valid(raise_exception=True):
|
||||||
return logger.info('Received invalid payload')
|
return logger.info("Received invalid payload")
|
||||||
|
|
||||||
return serializer.save()
|
return serializer.save()
|
||||||
|
|
||||||
def handle_undo_follow(self, ac, sender):
|
def handle_undo_follow(self, ac, sender):
|
||||||
system_actor = self.get_actor_instance()
|
system_actor = self.get_actor_instance()
|
||||||
serializer = serializers.UndoFollowSerializer(
|
serializer = serializers.UndoFollowSerializer(
|
||||||
data=ac, context={'actor': sender, 'target': system_actor})
|
data=ac, context={"actor": sender, "target": system_actor}
|
||||||
|
)
|
||||||
if not serializer.is_valid():
|
if not serializer.is_valid():
|
||||||
return logger.info('Received invalid payload')
|
return logger.info("Received invalid payload")
|
||||||
serializer.save()
|
serializer.save()
|
||||||
|
|
||||||
def handle_undo(self, ac, sender):
|
def handle_undo(self, ac, sender):
|
||||||
if ac['object']['type'] != 'Follow':
|
if ac["object"]["type"] != "Follow":
|
||||||
return
|
return
|
||||||
|
|
||||||
if ac['object']['actor'] != sender.url:
|
if ac["object"]["actor"] != sender.url:
|
||||||
# not the same actor, permission issue
|
# not the same actor, permission issue
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -206,55 +186,52 @@ class SystemActor(object):
|
||||||
|
|
||||||
|
|
||||||
class LibraryActor(SystemActor):
|
class LibraryActor(SystemActor):
|
||||||
id = 'library'
|
id = "library"
|
||||||
name = '{host}\'s library'
|
name = "{host}'s library"
|
||||||
summary = 'Bot account to federate with {host}\'s library'
|
summary = "Bot account to federate with {host}'s library"
|
||||||
additional_attributes = {
|
additional_attributes = {"manually_approves_followers": True}
|
||||||
'manually_approves_followers': True
|
|
||||||
}
|
|
||||||
|
|
||||||
def serialize(self):
|
def serialize(self):
|
||||||
data = super().serialize()
|
data = super().serialize()
|
||||||
urls = data.setdefault('url', [])
|
urls = data.setdefault("url", [])
|
||||||
urls.append({
|
urls.append(
|
||||||
'type': 'Link',
|
{
|
||||||
'mediaType': 'application/activity+json',
|
"type": "Link",
|
||||||
'name': 'library',
|
"mediaType": "application/activity+json",
|
||||||
'href': utils.full_url(reverse('federation:music:files-list'))
|
"name": "library",
|
||||||
})
|
"href": utils.full_url(reverse("federation:music:files-list")),
|
||||||
|
}
|
||||||
|
)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def manually_approves_followers(self):
|
def manually_approves_followers(self):
|
||||||
return preferences.get('federation__music_needs_approval')
|
return preferences.get("federation__music_needs_approval")
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def handle_create(self, ac, sender):
|
def handle_create(self, ac, sender):
|
||||||
try:
|
try:
|
||||||
remote_library = models.Library.objects.get(
|
remote_library = models.Library.objects.get(
|
||||||
actor=sender,
|
actor=sender, federation_enabled=True
|
||||||
federation_enabled=True,
|
|
||||||
)
|
)
|
||||||
except models.Library.DoesNotExist:
|
except models.Library.DoesNotExist:
|
||||||
logger.info(
|
logger.info("Skipping import, we're not following %s", sender.url)
|
||||||
'Skipping import, we\'re not following %s', sender.url)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if ac['object']['type'] != 'Collection':
|
if ac["object"]["type"] != "Collection":
|
||||||
return
|
return
|
||||||
|
|
||||||
if ac['object']['totalItems'] <= 0:
|
if ac["object"]["totalItems"] <= 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
items = ac['object']['items']
|
items = ac["object"]["items"]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
logger.warning('No items in collection!')
|
logger.warning("No items in collection!")
|
||||||
return
|
return
|
||||||
|
|
||||||
item_serializers = [
|
item_serializers = [
|
||||||
serializers.AudioSerializer(
|
serializers.AudioSerializer(data=i, context={"library": remote_library})
|
||||||
data=i, context={'library': remote_library})
|
|
||||||
for i in items
|
for i in items
|
||||||
]
|
]
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
|
@ -263,27 +240,21 @@ class LibraryActor(SystemActor):
|
||||||
if s.is_valid():
|
if s.is_valid():
|
||||||
valid_serializers.append(s)
|
valid_serializers.append(s)
|
||||||
else:
|
else:
|
||||||
logger.debug(
|
logger.debug("Skipping invalid item %s, %s", s.initial_data, s.errors)
|
||||||
'Skipping invalid item %s, %s', s.initial_data, s.errors)
|
|
||||||
|
|
||||||
lts = []
|
lts = []
|
||||||
for s in valid_serializers:
|
for s in valid_serializers:
|
||||||
lts.append(s.save())
|
lts.append(s.save())
|
||||||
|
|
||||||
if remote_library.autoimport:
|
if remote_library.autoimport:
|
||||||
batch = music_models.ImportBatch.objects.create(
|
batch = music_models.ImportBatch.objects.create(source="federation")
|
||||||
source='federation',
|
|
||||||
)
|
|
||||||
for lt in lts:
|
for lt in lts:
|
||||||
if lt.creation_date < now:
|
if lt.creation_date < now:
|
||||||
# track was already in the library, we do not trigger
|
# track was already in the library, we do not trigger
|
||||||
# an import
|
# an import
|
||||||
continue
|
continue
|
||||||
job = music_models.ImportJob.objects.create(
|
job = music_models.ImportJob.objects.create(
|
||||||
batch=batch,
|
batch=batch, library_track=lt, mbid=lt.mbid, source=lt.url
|
||||||
library_track=lt,
|
|
||||||
mbid=lt.mbid,
|
|
||||||
source=lt.url,
|
|
||||||
)
|
)
|
||||||
funkwhale_utils.on_commit(
|
funkwhale_utils.on_commit(
|
||||||
music_tasks.import_job_run.delay,
|
music_tasks.import_job_run.delay,
|
||||||
|
@ -293,15 +264,13 @@ class LibraryActor(SystemActor):
|
||||||
|
|
||||||
|
|
||||||
class TestActor(SystemActor):
|
class TestActor(SystemActor):
|
||||||
id = 'test'
|
id = "test"
|
||||||
name = '{host}\'s test account'
|
name = "{host}'s test account"
|
||||||
summary = (
|
summary = (
|
||||||
'Bot account to test federation with {host}. '
|
"Bot account to test federation with {host}. "
|
||||||
'Send me /ping and I\'ll answer you.'
|
"Send me /ping and I'll answer you."
|
||||||
)
|
)
|
||||||
additional_attributes = {
|
additional_attributes = {"manually_approves_followers": False}
|
||||||
'manually_approves_followers': False
|
|
||||||
}
|
|
||||||
manually_approves_followers = False
|
manually_approves_followers = False
|
||||||
|
|
||||||
def get_outbox(self, data, actor=None):
|
def get_outbox(self, data, actor=None):
|
||||||
|
@ -309,15 +278,14 @@ class TestActor(SystemActor):
|
||||||
"@context": [
|
"@context": [
|
||||||
"https://www.w3.org/ns/activitystreams",
|
"https://www.w3.org/ns/activitystreams",
|
||||||
"https://w3id.org/security/v1",
|
"https://w3id.org/security/v1",
|
||||||
{}
|
{},
|
||||||
],
|
],
|
||||||
"id": utils.full_url(
|
"id": utils.full_url(
|
||||||
reverse(
|
reverse("federation:instance-actors-outbox", kwargs={"actor": self.id})
|
||||||
'federation:instance-actors-outbox',
|
),
|
||||||
kwargs={'actor': self.id})),
|
|
||||||
"type": "OrderedCollection",
|
"type": "OrderedCollection",
|
||||||
"totalItems": 0,
|
"totalItems": 0,
|
||||||
"orderedItems": []
|
"orderedItems": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
def parse_command(self, message):
|
def parse_command(self, message):
|
||||||
|
@ -327,99 +295,85 @@ class TestActor(SystemActor):
|
||||||
"""
|
"""
|
||||||
raw = remove_tags(message)
|
raw = remove_tags(message)
|
||||||
try:
|
try:
|
||||||
return raw.split('/')[1]
|
return raw.split("/")[1]
|
||||||
except IndexError:
|
except IndexError:
|
||||||
return
|
return
|
||||||
|
|
||||||
def handle_create(self, ac, sender):
|
def handle_create(self, ac, sender):
|
||||||
if ac['object']['type'] != 'Note':
|
if ac["object"]["type"] != "Note":
|
||||||
return
|
return
|
||||||
|
|
||||||
# we received a toot \o/
|
# we received a toot \o/
|
||||||
command = self.parse_command(ac['object']['content'])
|
command = self.parse_command(ac["object"]["content"])
|
||||||
logger.debug('Parsed command: %s', command)
|
logger.debug("Parsed command: %s", command)
|
||||||
if command != 'ping':
|
if command != "ping":
|
||||||
return
|
return
|
||||||
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
test_actor = self.get_actor_instance()
|
test_actor = self.get_actor_instance()
|
||||||
reply_url = 'https://{}/activities/note/{}'.format(
|
reply_url = "https://{}/activities/note/{}".format(
|
||||||
settings.FEDERATION_HOSTNAME, now.timestamp()
|
settings.FEDERATION_HOSTNAME, now.timestamp()
|
||||||
)
|
)
|
||||||
reply_content = '{} Pong!'.format(
|
|
||||||
sender.mention_username
|
|
||||||
)
|
|
||||||
reply_activity = {
|
reply_activity = {
|
||||||
"@context": [
|
"@context": [
|
||||||
"https://www.w3.org/ns/activitystreams",
|
"https://www.w3.org/ns/activitystreams",
|
||||||
"https://w3id.org/security/v1",
|
"https://w3id.org/security/v1",
|
||||||
{}
|
{},
|
||||||
],
|
],
|
||||||
'type': 'Create',
|
"type": "Create",
|
||||||
'actor': test_actor.url,
|
"actor": test_actor.url,
|
||||||
'id': '{}/activity'.format(reply_url),
|
"id": "{}/activity".format(reply_url),
|
||||||
'published': now.isoformat(),
|
"published": now.isoformat(),
|
||||||
'to': ac['actor'],
|
"to": ac["actor"],
|
||||||
'cc': [],
|
"cc": [],
|
||||||
'object': {
|
"object": {
|
||||||
'type': 'Note',
|
"type": "Note",
|
||||||
'content': 'Pong!',
|
"content": "Pong!",
|
||||||
'summary': None,
|
"summary": None,
|
||||||
'published': now.isoformat(),
|
"published": now.isoformat(),
|
||||||
'id': reply_url,
|
"id": reply_url,
|
||||||
'inReplyTo': ac['object']['id'],
|
"inReplyTo": ac["object"]["id"],
|
||||||
'sensitive': False,
|
"sensitive": False,
|
||||||
'url': reply_url,
|
"url": reply_url,
|
||||||
'to': [ac['actor']],
|
"to": [ac["actor"]],
|
||||||
'attributedTo': test_actor.url,
|
"attributedTo": test_actor.url,
|
||||||
'cc': [],
|
"cc": [],
|
||||||
'attachment': [],
|
"attachment": [],
|
||||||
'tag': [{
|
"tag": [
|
||||||
|
{
|
||||||
"type": "Mention",
|
"type": "Mention",
|
||||||
"href": ac['actor'],
|
"href": ac["actor"],
|
||||||
"name": sender.mention_username
|
"name": sender.mention_username,
|
||||||
}]
|
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
activity.deliver(
|
activity.deliver(reply_activity, to=[ac["actor"]], on_behalf_of=test_actor)
|
||||||
reply_activity,
|
|
||||||
to=[ac['actor']],
|
|
||||||
on_behalf_of=test_actor)
|
|
||||||
|
|
||||||
def handle_follow(self, ac, sender):
|
def handle_follow(self, ac, sender):
|
||||||
super().handle_follow(ac, sender)
|
super().handle_follow(ac, sender)
|
||||||
# also, we follow back
|
# also, we follow back
|
||||||
test_actor = self.get_actor_instance()
|
test_actor = self.get_actor_instance()
|
||||||
follow_back = models.Follow.objects.get_or_create(
|
follow_back = models.Follow.objects.get_or_create(
|
||||||
actor=test_actor,
|
actor=test_actor, target=sender, approved=None
|
||||||
target=sender,
|
|
||||||
approved=None,
|
|
||||||
)[0]
|
)[0]
|
||||||
activity.deliver(
|
activity.deliver(
|
||||||
serializers.FollowSerializer(follow_back).data,
|
serializers.FollowSerializer(follow_back).data,
|
||||||
to=[follow_back.target.url],
|
to=[follow_back.target.url],
|
||||||
on_behalf_of=follow_back.actor)
|
on_behalf_of=follow_back.actor,
|
||||||
|
)
|
||||||
|
|
||||||
def handle_undo_follow(self, ac, sender):
|
def handle_undo_follow(self, ac, sender):
|
||||||
super().handle_undo_follow(ac, sender)
|
super().handle_undo_follow(ac, sender)
|
||||||
actor = self.get_actor_instance()
|
actor = self.get_actor_instance()
|
||||||
# we also unfollow the sender, if possible
|
# we also unfollow the sender, if possible
|
||||||
try:
|
try:
|
||||||
follow = models.Follow.objects.get(
|
follow = models.Follow.objects.get(target=sender, actor=actor)
|
||||||
target=sender,
|
|
||||||
actor=actor,
|
|
||||||
)
|
|
||||||
except models.Follow.DoesNotExist:
|
except models.Follow.DoesNotExist:
|
||||||
return
|
return
|
||||||
undo = serializers.UndoFollowSerializer(follow).data
|
undo = serializers.UndoFollowSerializer(follow).data
|
||||||
follow.delete()
|
follow.delete()
|
||||||
activity.deliver(
|
activity.deliver(undo, to=[sender.url], on_behalf_of=actor)
|
||||||
undo,
|
|
||||||
to=[sender.url],
|
|
||||||
on_behalf_of=actor)
|
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_ACTORS = {
|
SYSTEM_ACTORS = {"library": LibraryActor(), "test": TestActor()}
|
||||||
'library': LibraryActor(),
|
|
||||||
'test': TestActor(),
|
|
||||||
}
|
|
||||||
|
|
|
@ -6,61 +6,43 @@ from . import models
|
||||||
@admin.register(models.Actor)
|
@admin.register(models.Actor)
|
||||||
class ActorAdmin(admin.ModelAdmin):
|
class ActorAdmin(admin.ModelAdmin):
|
||||||
list_display = [
|
list_display = [
|
||||||
'url',
|
"url",
|
||||||
'domain',
|
"domain",
|
||||||
'preferred_username',
|
"preferred_username",
|
||||||
'type',
|
"type",
|
||||||
'creation_date',
|
"creation_date",
|
||||||
'last_fetch_date']
|
"last_fetch_date",
|
||||||
search_fields = ['url', 'domain', 'preferred_username']
|
|
||||||
list_filter = [
|
|
||||||
'type'
|
|
||||||
]
|
]
|
||||||
|
search_fields = ["url", "domain", "preferred_username"]
|
||||||
|
list_filter = ["type"]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.Follow)
|
@admin.register(models.Follow)
|
||||||
class FollowAdmin(admin.ModelAdmin):
|
class FollowAdmin(admin.ModelAdmin):
|
||||||
list_display = [
|
list_display = ["actor", "target", "approved", "creation_date"]
|
||||||
'actor',
|
list_filter = ["approved"]
|
||||||
'target',
|
search_fields = ["actor__url", "target__url"]
|
||||||
'approved',
|
|
||||||
'creation_date'
|
|
||||||
]
|
|
||||||
list_filter = [
|
|
||||||
'approved'
|
|
||||||
]
|
|
||||||
search_fields = ['actor__url', 'target__url']
|
|
||||||
list_select_related = True
|
list_select_related = True
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.Library)
|
@admin.register(models.Library)
|
||||||
class LibraryAdmin(admin.ModelAdmin):
|
class LibraryAdmin(admin.ModelAdmin):
|
||||||
list_display = [
|
list_display = ["actor", "url", "creation_date", "fetched_date", "tracks_count"]
|
||||||
'actor',
|
search_fields = ["actor__url", "url"]
|
||||||
'url',
|
list_filter = ["federation_enabled", "download_files", "autoimport"]
|
||||||
'creation_date',
|
|
||||||
'fetched_date',
|
|
||||||
'tracks_count']
|
|
||||||
search_fields = ['actor__url', 'url']
|
|
||||||
list_filter = [
|
|
||||||
'federation_enabled',
|
|
||||||
'download_files',
|
|
||||||
'autoimport',
|
|
||||||
]
|
|
||||||
list_select_related = True
|
list_select_related = True
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.LibraryTrack)
|
@admin.register(models.LibraryTrack)
|
||||||
class LibraryTrackAdmin(admin.ModelAdmin):
|
class LibraryTrackAdmin(admin.ModelAdmin):
|
||||||
list_display = [
|
list_display = [
|
||||||
'title',
|
"title",
|
||||||
'artist_name',
|
"artist_name",
|
||||||
'album_title',
|
"album_title",
|
||||||
'url',
|
"url",
|
||||||
'library',
|
"library",
|
||||||
'creation_date',
|
"creation_date",
|
||||||
'published_date',
|
"published_date",
|
||||||
]
|
]
|
||||||
search_fields = [
|
search_fields = ["library__url", "url", "artist_name", "title", "album_title"]
|
||||||
'library__url', 'url', 'artist_name', 'title', 'album_title']
|
|
||||||
list_select_related = True
|
list_select_related = True
|
||||||
|
|
|
@ -3,13 +3,7 @@ from rest_framework import routers
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
router = routers.SimpleRouter()
|
router = routers.SimpleRouter()
|
||||||
router.register(
|
router.register(r"libraries", views.LibraryViewSet, "libraries")
|
||||||
r'libraries',
|
router.register(r"library-tracks", views.LibraryTrackViewSet, "library-tracks")
|
||||||
views.LibraryViewSet,
|
|
||||||
'libraries')
|
|
||||||
router.register(
|
|
||||||
r'library-tracks',
|
|
||||||
views.LibraryTrackViewSet,
|
|
||||||
'library-tracks')
|
|
||||||
|
|
||||||
urlpatterns = router.urls
|
urlpatterns = router.urls
|
||||||
|
|
|
@ -1,23 +1,15 @@
|
||||||
import cryptography
|
import cryptography
|
||||||
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from rest_framework import authentication, exceptions
|
||||||
|
|
||||||
from rest_framework import authentication
|
from . import actors, keys, signing, utils
|
||||||
from rest_framework import exceptions
|
|
||||||
|
|
||||||
from . import actors
|
|
||||||
from . import keys
|
|
||||||
from . import models
|
|
||||||
from . import serializers
|
|
||||||
from . import signing
|
|
||||||
from . import utils
|
|
||||||
|
|
||||||
|
|
||||||
class SignatureAuthentication(authentication.BaseAuthentication):
|
class SignatureAuthentication(authentication.BaseAuthentication):
|
||||||
def authenticate_actor(self, request):
|
def authenticate_actor(self, request):
|
||||||
headers = utils.clean_wsgi_headers(request.META)
|
headers = utils.clean_wsgi_headers(request.META)
|
||||||
try:
|
try:
|
||||||
signature = headers['Signature']
|
signature = headers["Signature"]
|
||||||
key_id = keys.get_key_id_from_signature_header(signature)
|
key_id = keys.get_key_id_from_signature_header(signature)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return
|
return
|
||||||
|
@ -25,25 +17,25 @@ class SignatureAuthentication(authentication.BaseAuthentication):
|
||||||
raise exceptions.AuthenticationFailed(str(e))
|
raise exceptions.AuthenticationFailed(str(e))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
actor = actors.get_actor(key_id.split('#')[0])
|
actor = actors.get_actor(key_id.split("#")[0])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise exceptions.AuthenticationFailed(str(e))
|
raise exceptions.AuthenticationFailed(str(e))
|
||||||
|
|
||||||
if not actor.public_key:
|
if not actor.public_key:
|
||||||
raise exceptions.AuthenticationFailed('No public key found')
|
raise exceptions.AuthenticationFailed("No public key found")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
signing.verify_django(request, actor.public_key.encode('utf-8'))
|
signing.verify_django(request, actor.public_key.encode("utf-8"))
|
||||||
except cryptography.exceptions.InvalidSignature:
|
except cryptography.exceptions.InvalidSignature:
|
||||||
raise exceptions.AuthenticationFailed('Invalid signature')
|
raise exceptions.AuthenticationFailed("Invalid signature")
|
||||||
|
|
||||||
return actor
|
return actor
|
||||||
|
|
||||||
def authenticate(self, request):
|
def authenticate(self, request):
|
||||||
setattr(request, 'actor', None)
|
setattr(request, "actor", None)
|
||||||
actor = self.authenticate_actor(request)
|
actor = self.authenticate_actor(request)
|
||||||
if not actor:
|
if not actor:
|
||||||
return
|
return
|
||||||
user = AnonymousUser()
|
user = AnonymousUser()
|
||||||
setattr(request, 'actor', actor)
|
setattr(request, "actor", actor)
|
||||||
return (user, None)
|
return (user, None)
|
||||||
|
|
|
@ -1,80 +1,68 @@
|
||||||
from django.forms import widgets
|
|
||||||
|
|
||||||
from dynamic_preferences import types
|
from dynamic_preferences import types
|
||||||
from dynamic_preferences.registries import global_preferences_registry
|
from dynamic_preferences.registries import global_preferences_registry
|
||||||
|
|
||||||
from funkwhale_api.common import preferences
|
from funkwhale_api.common import preferences
|
||||||
federation = types.Section('federation')
|
|
||||||
|
federation = types.Section("federation")
|
||||||
|
|
||||||
|
|
||||||
@global_preferences_registry.register
|
@global_preferences_registry.register
|
||||||
class MusicCacheDuration(types.IntPreference):
|
class MusicCacheDuration(types.IntPreference):
|
||||||
show_in_api = True
|
show_in_api = True
|
||||||
section = federation
|
section = federation
|
||||||
name = 'music_cache_duration'
|
name = "music_cache_duration"
|
||||||
default = 60 * 24 * 2
|
default = 60 * 24 * 2
|
||||||
verbose_name = 'Music cache duration'
|
verbose_name = "Music cache duration"
|
||||||
help_text = (
|
help_text = (
|
||||||
'How much minutes do you want to keep a copy of federated tracks'
|
"How much minutes do you want to keep a copy of federated tracks"
|
||||||
'locally? Federated files that were not listened in this interval '
|
"locally? Federated files that were not listened in this interval "
|
||||||
'will be erased and refetched from the remote on the next listening.'
|
"will be erased and refetched from the remote on the next listening."
|
||||||
)
|
)
|
||||||
field_kwargs = {
|
field_kwargs = {"required": False}
|
||||||
'required': False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@global_preferences_registry.register
|
@global_preferences_registry.register
|
||||||
class Enabled(preferences.DefaultFromSettingMixin, types.BooleanPreference):
|
class Enabled(preferences.DefaultFromSettingMixin, types.BooleanPreference):
|
||||||
section = federation
|
section = federation
|
||||||
name = 'enabled'
|
name = "enabled"
|
||||||
setting = 'FEDERATION_ENABLED'
|
setting = "FEDERATION_ENABLED"
|
||||||
verbose_name = 'Federation enabled'
|
verbose_name = "Federation enabled"
|
||||||
help_text = (
|
help_text = (
|
||||||
'Use this setting to enable or disable federation logic and API'
|
"Use this setting to enable or disable federation logic and API" " globally."
|
||||||
' globally.'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@global_preferences_registry.register
|
@global_preferences_registry.register
|
||||||
class CollectionPageSize(
|
class CollectionPageSize(preferences.DefaultFromSettingMixin, types.IntPreference):
|
||||||
preferences.DefaultFromSettingMixin, types.IntPreference):
|
|
||||||
section = federation
|
section = federation
|
||||||
name = 'collection_page_size'
|
name = "collection_page_size"
|
||||||
setting = 'FEDERATION_COLLECTION_PAGE_SIZE'
|
setting = "FEDERATION_COLLECTION_PAGE_SIZE"
|
||||||
verbose_name = 'Federation collection page size'
|
verbose_name = "Federation collection page size"
|
||||||
help_text = (
|
help_text = "How much items to display in ActivityPub collections."
|
||||||
'How much items to display in ActivityPub collections.'
|
field_kwargs = {"required": False}
|
||||||
)
|
|
||||||
field_kwargs = {
|
|
||||||
'required': False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@global_preferences_registry.register
|
@global_preferences_registry.register
|
||||||
class ActorFetchDelay(
|
class ActorFetchDelay(preferences.DefaultFromSettingMixin, types.IntPreference):
|
||||||
preferences.DefaultFromSettingMixin, types.IntPreference):
|
|
||||||
section = federation
|
section = federation
|
||||||
name = 'actor_fetch_delay'
|
name = "actor_fetch_delay"
|
||||||
setting = 'FEDERATION_ACTOR_FETCH_DELAY'
|
setting = "FEDERATION_ACTOR_FETCH_DELAY"
|
||||||
verbose_name = 'Federation actor fetch delay'
|
verbose_name = "Federation actor fetch delay"
|
||||||
help_text = (
|
help_text = (
|
||||||
'How much minutes to wait before refetching actors on '
|
"How much minutes to wait before refetching actors on "
|
||||||
'request authentication.'
|
"request authentication."
|
||||||
)
|
)
|
||||||
field_kwargs = {
|
field_kwargs = {"required": False}
|
||||||
'required': False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@global_preferences_registry.register
|
@global_preferences_registry.register
|
||||||
class MusicNeedsApproval(
|
class MusicNeedsApproval(preferences.DefaultFromSettingMixin, types.BooleanPreference):
|
||||||
preferences.DefaultFromSettingMixin, types.BooleanPreference):
|
|
||||||
section = federation
|
section = federation
|
||||||
name = 'music_needs_approval'
|
name = "music_needs_approval"
|
||||||
setting = 'FEDERATION_MUSIC_NEEDS_APPROVAL'
|
setting = "FEDERATION_MUSIC_NEEDS_APPROVAL"
|
||||||
verbose_name = 'Federation music needs approval'
|
verbose_name = "Federation music needs approval"
|
||||||
help_text = (
|
help_text = (
|
||||||
'When true, other federation actors will need your approval'
|
"When true, other federation actors will need your approval"
|
||||||
' before being able to browse your library.'
|
" before being able to browse your library."
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
|
|
||||||
|
|
||||||
class MalformedPayload(ValueError):
|
class MalformedPayload(ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
@ -1,40 +1,34 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
import factory
|
import factory
|
||||||
import requests
|
import requests
|
||||||
import requests_http_signature
|
import requests_http_signature
|
||||||
import uuid
|
|
||||||
|
|
||||||
from django.utils import timezone
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from funkwhale_api.factories import registry
|
from funkwhale_api.factories import registry
|
||||||
|
|
||||||
from . import keys
|
from . import keys, models
|
||||||
from . import models
|
|
||||||
|
registry.register(keys.get_key_pair, name="federation.KeyPair")
|
||||||
|
|
||||||
|
|
||||||
registry.register(keys.get_key_pair, name='federation.KeyPair')
|
@registry.register(name="federation.SignatureAuth")
|
||||||
|
|
||||||
|
|
||||||
@registry.register(name='federation.SignatureAuth')
|
|
||||||
class SignatureAuthFactory(factory.Factory):
|
class SignatureAuthFactory(factory.Factory):
|
||||||
algorithm = 'rsa-sha256'
|
algorithm = "rsa-sha256"
|
||||||
key = factory.LazyFunction(lambda: keys.get_key_pair()[0])
|
key = factory.LazyFunction(lambda: keys.get_key_pair()[0])
|
||||||
key_id = factory.Faker('url')
|
key_id = factory.Faker("url")
|
||||||
use_auth_header = False
|
use_auth_header = False
|
||||||
headers = [
|
headers = ["(request-target)", "user-agent", "host", "date", "content-type"]
|
||||||
'(request-target)',
|
|
||||||
'user-agent',
|
|
||||||
'host',
|
|
||||||
'date',
|
|
||||||
'content-type',]
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = requests_http_signature.HTTPSignatureAuth
|
model = requests_http_signature.HTTPSignatureAuth
|
||||||
|
|
||||||
|
|
||||||
@registry.register(name='federation.SignedRequest')
|
@registry.register(name="federation.SignedRequest")
|
||||||
class SignedRequestFactory(factory.Factory):
|
class SignedRequestFactory(factory.Factory):
|
||||||
url = factory.Faker('url')
|
url = factory.Faker("url")
|
||||||
method = 'get'
|
method = "get"
|
||||||
auth = factory.SubFactory(SignatureAuthFactory)
|
auth = factory.SubFactory(SignatureAuthFactory)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -43,59 +37,62 @@ class SignedRequestFactory(factory.Factory):
|
||||||
@factory.post_generation
|
@factory.post_generation
|
||||||
def headers(self, create, extracted, **kwargs):
|
def headers(self, create, extracted, **kwargs):
|
||||||
default_headers = {
|
default_headers = {
|
||||||
'User-Agent': 'Test',
|
"User-Agent": "Test",
|
||||||
'Host': 'test.host',
|
"Host": "test.host",
|
||||||
'Date': 'Right now',
|
"Date": "Right now",
|
||||||
'Content-Type': 'application/activity+json'
|
"Content-Type": "application/activity+json",
|
||||||
}
|
}
|
||||||
if extracted:
|
if extracted:
|
||||||
default_headers.update(extracted)
|
default_headers.update(extracted)
|
||||||
self.headers.update(default_headers)
|
self.headers.update(default_headers)
|
||||||
|
|
||||||
|
|
||||||
@registry.register(name='federation.Link')
|
@registry.register(name="federation.Link")
|
||||||
class LinkFactory(factory.Factory):
|
class LinkFactory(factory.Factory):
|
||||||
type = 'Link'
|
type = "Link"
|
||||||
href = factory.Faker('url')
|
href = factory.Faker("url")
|
||||||
mediaType = 'text/html'
|
mediaType = "text/html"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = dict
|
model = dict
|
||||||
|
|
||||||
class Params:
|
class Params:
|
||||||
audio = factory.Trait(
|
audio = factory.Trait(mediaType=factory.Iterator(["audio/mp3", "audio/ogg"]))
|
||||||
mediaType=factory.Iterator(['audio/mp3', 'audio/ogg'])
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@registry.register
|
@registry.register
|
||||||
class ActorFactory(factory.DjangoModelFactory):
|
class ActorFactory(factory.DjangoModelFactory):
|
||||||
public_key = None
|
public_key = None
|
||||||
private_key = None
|
private_key = None
|
||||||
preferred_username = factory.Faker('user_name')
|
preferred_username = factory.Faker("user_name")
|
||||||
summary = factory.Faker('paragraph')
|
summary = factory.Faker("paragraph")
|
||||||
domain = factory.Faker('domain_name')
|
domain = factory.Faker("domain_name")
|
||||||
url = factory.LazyAttribute(lambda o: 'https://{}/users/{}'.format(o.domain, o.preferred_username))
|
url = factory.LazyAttribute(
|
||||||
inbox_url = factory.LazyAttribute(lambda o: 'https://{}/users/{}/inbox'.format(o.domain, o.preferred_username))
|
lambda o: "https://{}/users/{}".format(o.domain, o.preferred_username)
|
||||||
outbox_url = factory.LazyAttribute(lambda o: 'https://{}/users/{}/outbox'.format(o.domain, o.preferred_username))
|
)
|
||||||
|
inbox_url = factory.LazyAttribute(
|
||||||
|
lambda o: "https://{}/users/{}/inbox".format(o.domain, o.preferred_username)
|
||||||
|
)
|
||||||
|
outbox_url = factory.LazyAttribute(
|
||||||
|
lambda o: "https://{}/users/{}/outbox".format(o.domain, o.preferred_username)
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Actor
|
model = models.Actor
|
||||||
|
|
||||||
class Params:
|
class Params:
|
||||||
local = factory.Trait(
|
local = factory.Trait(
|
||||||
domain=factory.LazyAttribute(
|
domain=factory.LazyAttribute(lambda o: settings.FEDERATION_HOSTNAME)
|
||||||
lambda o: settings.FEDERATION_HOSTNAME)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _generate(cls, create, attrs):
|
def _generate(cls, create, attrs):
|
||||||
has_public = attrs.get('public_key') is not None
|
has_public = attrs.get("public_key") is not None
|
||||||
has_private = attrs.get('private_key') is not None
|
has_private = attrs.get("private_key") is not None
|
||||||
if not has_public and not has_private:
|
if not has_public and not has_private:
|
||||||
private, public = keys.get_key_pair()
|
private, public = keys.get_key_pair()
|
||||||
attrs['private_key'] = private.decode('utf-8')
|
attrs["private_key"] = private.decode("utf-8")
|
||||||
attrs['public_key'] = public.decode('utf-8')
|
attrs["public_key"] = public.decode("utf-8")
|
||||||
return super()._generate(create, attrs)
|
return super()._generate(create, attrs)
|
||||||
|
|
||||||
|
|
||||||
|
@ -108,15 +105,13 @@ class FollowFactory(factory.DjangoModelFactory):
|
||||||
model = models.Follow
|
model = models.Follow
|
||||||
|
|
||||||
class Params:
|
class Params:
|
||||||
local = factory.Trait(
|
local = factory.Trait(actor=factory.SubFactory(ActorFactory, local=True))
|
||||||
actor=factory.SubFactory(ActorFactory, local=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@registry.register
|
@registry.register
|
||||||
class LibraryFactory(factory.DjangoModelFactory):
|
class LibraryFactory(factory.DjangoModelFactory):
|
||||||
actor = factory.SubFactory(ActorFactory)
|
actor = factory.SubFactory(ActorFactory)
|
||||||
url = factory.Faker('url')
|
url = factory.Faker("url")
|
||||||
federation_enabled = True
|
federation_enabled = True
|
||||||
download_files = False
|
download_files = False
|
||||||
autoimport = False
|
autoimport = False
|
||||||
|
@ -126,42 +121,36 @@ class LibraryFactory(factory.DjangoModelFactory):
|
||||||
|
|
||||||
|
|
||||||
class ArtistMetadataFactory(factory.Factory):
|
class ArtistMetadataFactory(factory.Factory):
|
||||||
name = factory.Faker('name')
|
name = factory.Faker("name")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = dict
|
model = dict
|
||||||
|
|
||||||
class Params:
|
class Params:
|
||||||
musicbrainz = factory.Trait(
|
musicbrainz = factory.Trait(musicbrainz_id=factory.Faker("uuid4"))
|
||||||
musicbrainz_id=factory.Faker('uuid4')
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ReleaseMetadataFactory(factory.Factory):
|
class ReleaseMetadataFactory(factory.Factory):
|
||||||
title = factory.Faker('sentence')
|
title = factory.Faker("sentence")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = dict
|
model = dict
|
||||||
|
|
||||||
class Params:
|
class Params:
|
||||||
musicbrainz = factory.Trait(
|
musicbrainz = factory.Trait(musicbrainz_id=factory.Faker("uuid4"))
|
||||||
musicbrainz_id=factory.Faker('uuid4')
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RecordingMetadataFactory(factory.Factory):
|
class RecordingMetadataFactory(factory.Factory):
|
||||||
title = factory.Faker('sentence')
|
title = factory.Faker("sentence")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = dict
|
model = dict
|
||||||
|
|
||||||
class Params:
|
class Params:
|
||||||
musicbrainz = factory.Trait(
|
musicbrainz = factory.Trait(musicbrainz_id=factory.Faker("uuid4"))
|
||||||
musicbrainz_id=factory.Faker('uuid4')
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@registry.register(name='federation.LibraryTrackMetadata')
|
@registry.register(name="federation.LibraryTrackMetadata")
|
||||||
class LibraryTrackMetadataFactory(factory.Factory):
|
class LibraryTrackMetadataFactory(factory.Factory):
|
||||||
artist = factory.SubFactory(ArtistMetadataFactory)
|
artist = factory.SubFactory(ArtistMetadataFactory)
|
||||||
recording = factory.SubFactory(RecordingMetadataFactory)
|
recording = factory.SubFactory(RecordingMetadataFactory)
|
||||||
|
@ -174,64 +163,59 @@ class LibraryTrackMetadataFactory(factory.Factory):
|
||||||
@registry.register
|
@registry.register
|
||||||
class LibraryTrackFactory(factory.DjangoModelFactory):
|
class LibraryTrackFactory(factory.DjangoModelFactory):
|
||||||
library = factory.SubFactory(LibraryFactory)
|
library = factory.SubFactory(LibraryFactory)
|
||||||
url = factory.Faker('url')
|
url = factory.Faker("url")
|
||||||
title = factory.Faker('sentence')
|
title = factory.Faker("sentence")
|
||||||
artist_name = factory.Faker('sentence')
|
artist_name = factory.Faker("sentence")
|
||||||
album_title = factory.Faker('sentence')
|
album_title = factory.Faker("sentence")
|
||||||
audio_url = factory.Faker('url')
|
audio_url = factory.Faker("url")
|
||||||
audio_mimetype = 'audio/ogg'
|
audio_mimetype = "audio/ogg"
|
||||||
metadata = factory.SubFactory(LibraryTrackMetadataFactory)
|
metadata = factory.SubFactory(LibraryTrackMetadataFactory)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.LibraryTrack
|
model = models.LibraryTrack
|
||||||
|
|
||||||
class Params:
|
class Params:
|
||||||
with_audio_file = factory.Trait(
|
with_audio_file = factory.Trait(audio_file=factory.django.FileField())
|
||||||
audio_file=factory.django.FileField()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@registry.register(name='federation.Note')
|
@registry.register(name="federation.Note")
|
||||||
class NoteFactory(factory.Factory):
|
class NoteFactory(factory.Factory):
|
||||||
type = 'Note'
|
type = "Note"
|
||||||
id = factory.Faker('url')
|
id = factory.Faker("url")
|
||||||
published = factory.LazyFunction(
|
published = factory.LazyFunction(lambda: timezone.now().isoformat())
|
||||||
lambda: timezone.now().isoformat()
|
|
||||||
)
|
|
||||||
inReplyTo = None
|
inReplyTo = None
|
||||||
content = factory.Faker('sentence')
|
content = factory.Faker("sentence")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = dict
|
model = dict
|
||||||
|
|
||||||
|
|
||||||
@registry.register(name='federation.Activity')
|
@registry.register(name="federation.Activity")
|
||||||
class ActivityFactory(factory.Factory):
|
class ActivityFactory(factory.Factory):
|
||||||
type = 'Create'
|
type = "Create"
|
||||||
id = factory.Faker('url')
|
id = factory.Faker("url")
|
||||||
published = factory.LazyFunction(
|
published = factory.LazyFunction(lambda: timezone.now().isoformat())
|
||||||
lambda: timezone.now().isoformat()
|
actor = factory.Faker("url")
|
||||||
)
|
|
||||||
actor = factory.Faker('url')
|
|
||||||
object = factory.SubFactory(
|
object = factory.SubFactory(
|
||||||
NoteFactory,
|
NoteFactory,
|
||||||
actor=factory.SelfAttribute('..actor'),
|
actor=factory.SelfAttribute("..actor"),
|
||||||
published=factory.SelfAttribute('..published'))
|
published=factory.SelfAttribute("..published"),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = dict
|
model = dict
|
||||||
|
|
||||||
|
|
||||||
@registry.register(name='federation.AudioMetadata')
|
@registry.register(name="federation.AudioMetadata")
|
||||||
class AudioMetadataFactory(factory.Factory):
|
class AudioMetadataFactory(factory.Factory):
|
||||||
recording = factory.LazyAttribute(
|
recording = factory.LazyAttribute(
|
||||||
lambda o: 'https://musicbrainz.org/recording/{}'.format(uuid.uuid4())
|
lambda o: "https://musicbrainz.org/recording/{}".format(uuid.uuid4())
|
||||||
)
|
)
|
||||||
artist = factory.LazyAttribute(
|
artist = factory.LazyAttribute(
|
||||||
lambda o: 'https://musicbrainz.org/artist/{}'.format(uuid.uuid4())
|
lambda o: "https://musicbrainz.org/artist/{}".format(uuid.uuid4())
|
||||||
)
|
)
|
||||||
release = factory.LazyAttribute(
|
release = factory.LazyAttribute(
|
||||||
lambda o: 'https://musicbrainz.org/release/{}'.format(uuid.uuid4())
|
lambda o: "https://musicbrainz.org/release/{}".format(uuid.uuid4())
|
||||||
)
|
)
|
||||||
bitrate = 42
|
bitrate = 42
|
||||||
length = 43
|
length = 43
|
||||||
|
@ -241,14 +225,12 @@ class AudioMetadataFactory(factory.Factory):
|
||||||
model = dict
|
model = dict
|
||||||
|
|
||||||
|
|
||||||
@registry.register(name='federation.Audio')
|
@registry.register(name="federation.Audio")
|
||||||
class AudioFactory(factory.Factory):
|
class AudioFactory(factory.Factory):
|
||||||
type = 'Audio'
|
type = "Audio"
|
||||||
id = factory.Faker('url')
|
id = factory.Faker("url")
|
||||||
published = factory.LazyFunction(
|
published = factory.LazyFunction(lambda: timezone.now().isoformat())
|
||||||
lambda: timezone.now().isoformat()
|
actor = factory.Faker("url")
|
||||||
)
|
|
||||||
actor = factory.Faker('url')
|
|
||||||
url = factory.SubFactory(LinkFactory, audio=True)
|
url = factory.SubFactory(LinkFactory, audio=True)
|
||||||
metadata = factory.SubFactory(LibraryTrackMetadataFactory)
|
metadata = factory.SubFactory(LibraryTrackMetadataFactory)
|
||||||
|
|
||||||
|
|
|
@ -6,73 +6,67 @@ from . import models
|
||||||
|
|
||||||
|
|
||||||
class LibraryFilter(django_filters.FilterSet):
|
class LibraryFilter(django_filters.FilterSet):
|
||||||
approved = django_filters.BooleanFilter('following__approved')
|
approved = django_filters.BooleanFilter("following__approved")
|
||||||
q = fields.SearchFilter(search_fields=[
|
q = fields.SearchFilter(search_fields=["actor__domain"])
|
||||||
'actor__domain',
|
|
||||||
])
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Library
|
model = models.Library
|
||||||
fields = {
|
fields = {
|
||||||
'approved': ['exact'],
|
"approved": ["exact"],
|
||||||
'federation_enabled': ['exact'],
|
"federation_enabled": ["exact"],
|
||||||
'download_files': ['exact'],
|
"download_files": ["exact"],
|
||||||
'autoimport': ['exact'],
|
"autoimport": ["exact"],
|
||||||
'tracks_count': ['exact'],
|
"tracks_count": ["exact"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class LibraryTrackFilter(django_filters.FilterSet):
|
class LibraryTrackFilter(django_filters.FilterSet):
|
||||||
library = django_filters.CharFilter('library__uuid')
|
library = django_filters.CharFilter("library__uuid")
|
||||||
status = django_filters.CharFilter(method='filter_status')
|
status = django_filters.CharFilter(method="filter_status")
|
||||||
q = fields.SearchFilter(search_fields=[
|
q = fields.SearchFilter(
|
||||||
'artist_name',
|
search_fields=["artist_name", "title", "album_title", "library__actor__domain"]
|
||||||
'title',
|
)
|
||||||
'album_title',
|
|
||||||
'library__actor__domain',
|
|
||||||
])
|
|
||||||
|
|
||||||
def filter_status(self, queryset, field_name, value):
|
def filter_status(self, queryset, field_name, value):
|
||||||
if value == 'imported':
|
if value == "imported":
|
||||||
return queryset.filter(local_track_file__isnull=False)
|
return queryset.filter(local_track_file__isnull=False)
|
||||||
elif value == 'not_imported':
|
elif value == "not_imported":
|
||||||
return queryset.filter(
|
return queryset.filter(local_track_file__isnull=True).exclude(
|
||||||
local_track_file__isnull=True
|
import_jobs__status="pending"
|
||||||
).exclude(import_jobs__status='pending')
|
)
|
||||||
elif value == 'import_pending':
|
elif value == "import_pending":
|
||||||
return queryset.filter(import_jobs__status='pending')
|
return queryset.filter(import_jobs__status="pending")
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.LibraryTrack
|
model = models.LibraryTrack
|
||||||
fields = {
|
fields = {
|
||||||
'library': ['exact'],
|
"library": ["exact"],
|
||||||
'artist_name': ['exact', 'icontains'],
|
"artist_name": ["exact", "icontains"],
|
||||||
'title': ['exact', 'icontains'],
|
"title": ["exact", "icontains"],
|
||||||
'album_title': ['exact', 'icontains'],
|
"album_title": ["exact", "icontains"],
|
||||||
'audio_mimetype': ['exact', 'icontains'],
|
"audio_mimetype": ["exact", "icontains"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class FollowFilter(django_filters.FilterSet):
|
class FollowFilter(django_filters.FilterSet):
|
||||||
pending = django_filters.CharFilter(method='filter_pending')
|
pending = django_filters.CharFilter(method="filter_pending")
|
||||||
ordering = django_filters.OrderingFilter(
|
ordering = django_filters.OrderingFilter(
|
||||||
# tuple-mapping retains order
|
# tuple-mapping retains order
|
||||||
fields=(
|
fields=(
|
||||||
('creation_date', 'creation_date'),
|
("creation_date", "creation_date"),
|
||||||
('modification_date', 'modification_date'),
|
("modification_date", "modification_date"),
|
||||||
),
|
)
|
||||||
|
)
|
||||||
|
q = fields.SearchFilter(
|
||||||
|
search_fields=["actor__domain", "actor__preferred_username"]
|
||||||
)
|
)
|
||||||
q = fields.SearchFilter(search_fields=[
|
|
||||||
'actor__domain',
|
|
||||||
'actor__preferred_username',
|
|
||||||
])
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Follow
|
model = models.Follow
|
||||||
fields = ['approved', 'pending', 'q']
|
fields = ["approved", "pending", "q"]
|
||||||
|
|
||||||
def filter_pending(self, queryset, field_name, value):
|
def filter_pending(self, queryset, field_name, value):
|
||||||
if value.lower() in ['true', '1', 'yes']:
|
if value.lower() in ["true", "1", "yes"]:
|
||||||
queryset = queryset.filter(approved__isnull=True)
|
queryset = queryset.filter(approved__isnull=True)
|
||||||
return queryset
|
return queryset
|
||||||
|
|
|
@ -1,48 +1,44 @@
|
||||||
from cryptography.hazmat.primitives import serialization as crypto_serialization
|
|
||||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
||||||
from cryptography.hazmat.backends import default_backend as crypto_default_backend
|
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
from . import exceptions
|
from cryptography.hazmat.backends import default_backend as crypto_default_backend
|
||||||
|
from cryptography.hazmat.primitives import serialization as crypto_serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
|
||||||
KEY_ID_REGEX = re.compile(r'keyId=\"(?P<id>.*)\"')
|
KEY_ID_REGEX = re.compile(r"keyId=\"(?P<id>.*)\"")
|
||||||
|
|
||||||
|
|
||||||
def get_key_pair(size=2048):
|
def get_key_pair(size=2048):
|
||||||
key = rsa.generate_private_key(
|
key = rsa.generate_private_key(
|
||||||
backend=crypto_default_backend(),
|
backend=crypto_default_backend(), public_exponent=65537, key_size=size
|
||||||
public_exponent=65537,
|
|
||||||
key_size=size
|
|
||||||
)
|
)
|
||||||
private_key = key.private_bytes(
|
private_key = key.private_bytes(
|
||||||
crypto_serialization.Encoding.PEM,
|
crypto_serialization.Encoding.PEM,
|
||||||
crypto_serialization.PrivateFormat.PKCS8,
|
crypto_serialization.PrivateFormat.PKCS8,
|
||||||
crypto_serialization.NoEncryption())
|
crypto_serialization.NoEncryption(),
|
||||||
|
)
|
||||||
public_key = key.public_key().public_bytes(
|
public_key = key.public_key().public_bytes(
|
||||||
crypto_serialization.Encoding.PEM,
|
crypto_serialization.Encoding.PEM, crypto_serialization.PublicFormat.PKCS1
|
||||||
crypto_serialization.PublicFormat.PKCS1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return private_key, public_key
|
return private_key, public_key
|
||||||
|
|
||||||
|
|
||||||
def get_key_id_from_signature_header(header_string):
|
def get_key_id_from_signature_header(header_string):
|
||||||
parts = header_string.split(',')
|
parts = header_string.split(",")
|
||||||
try:
|
try:
|
||||||
raw_key_id = [p for p in parts if p.startswith('keyId="')][0]
|
raw_key_id = [p for p in parts if p.startswith('keyId="')][0]
|
||||||
except IndexError:
|
except IndexError:
|
||||||
raise ValueError('Missing key id')
|
raise ValueError("Missing key id")
|
||||||
|
|
||||||
match = KEY_ID_REGEX.match(raw_key_id)
|
match = KEY_ID_REGEX.match(raw_key_id)
|
||||||
if not match:
|
if not match:
|
||||||
raise ValueError('Invalid key id')
|
raise ValueError("Invalid key id")
|
||||||
|
|
||||||
key_id = match.groups()[0]
|
key_id = match.groups()[0]
|
||||||
url = urllib.parse.urlparse(key_id)
|
url = urllib.parse.urlparse(key_id)
|
||||||
if not url.scheme or not url.netloc:
|
if not url.scheme or not url.netloc:
|
||||||
raise ValueError('Invalid url')
|
raise ValueError("Invalid url")
|
||||||
if url.scheme not in ['http', 'https']:
|
if url.scheme not in ["http", "https"]:
|
||||||
raise ValueError('Invalid shceme')
|
raise ValueError("Invalid shceme")
|
||||||
return key_id
|
return key_id
|
||||||
|
|
|
@ -1,15 +1,11 @@
|
||||||
import json
|
import json
|
||||||
import requests
|
|
||||||
|
|
||||||
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from funkwhale_api.common import session
|
from funkwhale_api.common import session
|
||||||
|
|
||||||
from . import actors
|
from . import actors, models, serializers, signing, webfinger
|
||||||
from . import models
|
|
||||||
from . import serializers
|
|
||||||
from . import signing
|
|
||||||
from . import webfinger
|
|
||||||
|
|
||||||
|
|
||||||
def scan_from_account_name(account_name):
|
def scan_from_account_name(account_name):
|
||||||
|
@ -24,87 +20,59 @@ def scan_from_account_name(account_name):
|
||||||
"""
|
"""
|
||||||
data = {}
|
data = {}
|
||||||
try:
|
try:
|
||||||
username, domain = webfinger.clean_acct(
|
username, domain = webfinger.clean_acct(account_name, ensure_local=False)
|
||||||
account_name, ensure_local=False)
|
|
||||||
except serializers.ValidationError:
|
except serializers.ValidationError:
|
||||||
return {
|
return {"webfinger": {"errors": ["Invalid account string"]}}
|
||||||
'webfinger': {
|
system_library = actors.SYSTEM_ACTORS["library"].get_actor_instance()
|
||||||
'errors': ['Invalid account string']
|
data["local"] = {"following": False, "awaiting_approval": False}
|
||||||
}
|
|
||||||
}
|
|
||||||
system_library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
|
||||||
library = models.Library.objects.filter(
|
|
||||||
actor__domain=domain,
|
|
||||||
actor__preferred_username=username
|
|
||||||
).select_related('actor').first()
|
|
||||||
data['local'] = {
|
|
||||||
'following': False,
|
|
||||||
'awaiting_approval': False,
|
|
||||||
}
|
|
||||||
try:
|
try:
|
||||||
follow = models.Follow.objects.get(
|
follow = models.Follow.objects.get(
|
||||||
target__preferred_username=username,
|
target__preferred_username=username,
|
||||||
target__domain=username,
|
target__domain=username,
|
||||||
actor=system_library,
|
actor=system_library,
|
||||||
)
|
)
|
||||||
data['local']['awaiting_approval'] = not bool(follow.approved)
|
data["local"]["awaiting_approval"] = not bool(follow.approved)
|
||||||
data['local']['following'] = True
|
data["local"]["following"] = True
|
||||||
except models.Follow.DoesNotExist:
|
except models.Follow.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data['webfinger'] = webfinger.get_resource(
|
data["webfinger"] = webfinger.get_resource("acct:{}".format(account_name))
|
||||||
'acct:{}'.format(account_name))
|
|
||||||
except requests.ConnectionError:
|
except requests.ConnectionError:
|
||||||
return {
|
return {"webfinger": {"errors": ["This webfinger resource is not reachable"]}}
|
||||||
'webfinger': {
|
|
||||||
'errors': ['This webfinger resource is not reachable']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
except requests.HTTPError as e:
|
except requests.HTTPError as e:
|
||||||
return {
|
return {
|
||||||
'webfinger': {
|
"webfinger": {
|
||||||
'errors': [
|
"errors": [
|
||||||
'Error {} during webfinger request'.format(
|
"Error {} during webfinger request".format(e.response.status_code)
|
||||||
e.response.status_code)]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
return {
|
return {"webfinger": {"errors": ["Could not process webfinger response"]}}
|
||||||
'webfinger': {
|
|
||||||
'errors': ['Could not process webfinger response']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data['actor'] = actors.get_actor_data(data['webfinger']['actor_url'])
|
data["actor"] = actors.get_actor_data(data["webfinger"]["actor_url"])
|
||||||
except requests.ConnectionError:
|
except requests.ConnectionError:
|
||||||
data['actor'] = {
|
data["actor"] = {"errors": ["This actor is not reachable"]}
|
||||||
'errors': ['This actor is not reachable']
|
|
||||||
}
|
|
||||||
return data
|
return data
|
||||||
except requests.HTTPError as e:
|
except requests.HTTPError as e:
|
||||||
data['actor'] = {
|
data["actor"] = {
|
||||||
'errors': [
|
"errors": ["Error {} during actor request".format(e.response.status_code)]
|
||||||
'Error {} during actor request'.format(
|
|
||||||
e.response.status_code)]
|
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
|
|
||||||
serializer = serializers.LibraryActorSerializer(data=data['actor'])
|
serializer = serializers.LibraryActorSerializer(data=data["actor"])
|
||||||
if not serializer.is_valid():
|
if not serializer.is_valid():
|
||||||
data['actor'] = {
|
data["actor"] = {"errors": ["Invalid ActivityPub actor"]}
|
||||||
'errors': ['Invalid ActivityPub actor']
|
|
||||||
}
|
|
||||||
return data
|
return data
|
||||||
data['library'] = get_library_data(
|
data["library"] = get_library_data(serializer.validated_data["library_url"])
|
||||||
serializer.validated_data['library_url'])
|
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
def get_library_data(library_url):
|
def get_library_data(library_url):
|
||||||
actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
|
||||||
auth = signing.get_auth(actor.private_key, actor.private_key_id)
|
auth = signing.get_auth(actor.private_key, actor.private_key_id)
|
||||||
try:
|
try:
|
||||||
response = session.get_session().get(
|
response = session.get_session().get(
|
||||||
|
@ -112,55 +80,37 @@ def get_library_data(library_url):
|
||||||
auth=auth,
|
auth=auth,
|
||||||
timeout=5,
|
timeout=5,
|
||||||
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
|
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
|
||||||
headers={
|
headers={"Content-Type": "application/activity+json"},
|
||||||
'Content-Type': 'application/activity+json'
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
except requests.ConnectionError:
|
except requests.ConnectionError:
|
||||||
return {
|
return {"errors": ["This library is not reachable"]}
|
||||||
'errors': ['This library is not reachable']
|
|
||||||
}
|
|
||||||
scode = response.status_code
|
scode = response.status_code
|
||||||
if scode == 401:
|
if scode == 401:
|
||||||
return {
|
return {"errors": ["This library requires authentication"]}
|
||||||
'errors': ['This library requires authentication']
|
|
||||||
}
|
|
||||||
elif scode == 403:
|
elif scode == 403:
|
||||||
return {
|
return {"errors": ["Permission denied while scanning library"]}
|
||||||
'errors': ['Permission denied while scanning library']
|
|
||||||
}
|
|
||||||
elif scode >= 400:
|
elif scode >= 400:
|
||||||
return {
|
return {"errors": ["Error {} while fetching the library".format(scode)]}
|
||||||
'errors': ['Error {} while fetching the library'.format(scode)]
|
serializer = serializers.PaginatedCollectionSerializer(data=response.json())
|
||||||
}
|
|
||||||
serializer = serializers.PaginatedCollectionSerializer(
|
|
||||||
data=response.json(),
|
|
||||||
)
|
|
||||||
if not serializer.is_valid():
|
if not serializer.is_valid():
|
||||||
return {
|
return {"errors": ["Invalid ActivityPub response from remote library"]}
|
||||||
'errors': [
|
|
||||||
'Invalid ActivityPub response from remote library']
|
|
||||||
}
|
|
||||||
|
|
||||||
return serializer.validated_data
|
return serializer.validated_data
|
||||||
|
|
||||||
|
|
||||||
def get_library_page(library, page_url):
|
def get_library_page(library, page_url):
|
||||||
actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
|
||||||
auth = signing.get_auth(actor.private_key, actor.private_key_id)
|
auth = signing.get_auth(actor.private_key, actor.private_key_id)
|
||||||
response = session.get_session().get(
|
response = session.get_session().get(
|
||||||
page_url,
|
page_url,
|
||||||
auth=auth,
|
auth=auth,
|
||||||
timeout=5,
|
timeout=5,
|
||||||
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
|
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
|
||||||
headers={
|
headers={"Content-Type": "application/activity+json"},
|
||||||
'Content-Type': 'application/activity+json'
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
serializer = serializers.CollectionPageSerializer(
|
serializer = serializers.CollectionPageSerializer(
|
||||||
data=response.json(),
|
data=response.json(),
|
||||||
context={
|
context={"library": library, "item_serializer": serializers.AudioSerializer},
|
||||||
'library': library,
|
)
|
||||||
'item_serializer': serializers.AudioSerializer})
|
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
return serializer.validated_data
|
return serializer.validated_data
|
||||||
|
|
|
@ -8,30 +8,74 @@ class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = []
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Actor',
|
name="Actor",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('url', models.URLField(db_index=True, max_length=500, unique=True)),
|
"id",
|
||||||
('outbox_url', models.URLField(max_length=500)),
|
models.AutoField(
|
||||||
('inbox_url', models.URLField(max_length=500)),
|
auto_created=True,
|
||||||
('following_url', models.URLField(blank=True, max_length=500, null=True)),
|
primary_key=True,
|
||||||
('followers_url', models.URLField(blank=True, max_length=500, null=True)),
|
serialize=False,
|
||||||
('shared_inbox_url', models.URLField(blank=True, max_length=500, null=True)),
|
verbose_name="ID",
|
||||||
('type', models.CharField(choices=[('Person', 'Person'), ('Application', 'Application'), ('Group', 'Group'), ('Organization', 'Organization'), ('Service', 'Service')], default='Person', max_length=25)),
|
|
||||||
('name', models.CharField(blank=True, max_length=200, null=True)),
|
|
||||||
('domain', models.CharField(max_length=1000)),
|
|
||||||
('summary', models.CharField(blank=True, max_length=500, null=True)),
|
|
||||||
('preferred_username', models.CharField(blank=True, max_length=200, null=True)),
|
|
||||||
('public_key', models.CharField(blank=True, max_length=5000, null=True)),
|
|
||||||
('private_key', models.CharField(blank=True, max_length=5000, null=True)),
|
|
||||||
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
|
|
||||||
('last_fetch_date', models.DateTimeField(default=django.utils.timezone.now)),
|
|
||||||
('manually_approves_followers', models.NullBooleanField(default=None)),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
("url", models.URLField(db_index=True, max_length=500, unique=True)),
|
||||||
|
("outbox_url", models.URLField(max_length=500)),
|
||||||
|
("inbox_url", models.URLField(max_length=500)),
|
||||||
|
(
|
||||||
|
"following_url",
|
||||||
|
models.URLField(blank=True, max_length=500, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"followers_url",
|
||||||
|
models.URLField(blank=True, max_length=500, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"shared_inbox_url",
|
||||||
|
models.URLField(blank=True, max_length=500, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"type",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("Person", "Person"),
|
||||||
|
("Application", "Application"),
|
||||||
|
("Group", "Group"),
|
||||||
|
("Organization", "Organization"),
|
||||||
|
("Service", "Service"),
|
||||||
|
],
|
||||||
|
default="Person",
|
||||||
|
max_length=25,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(blank=True, max_length=200, null=True)),
|
||||||
|
("domain", models.CharField(max_length=1000)),
|
||||||
|
("summary", models.CharField(blank=True, max_length=500, null=True)),
|
||||||
|
(
|
||||||
|
"preferred_username",
|
||||||
|
models.CharField(blank=True, max_length=200, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"public_key",
|
||||||
|
models.CharField(blank=True, max_length=5000, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"private_key",
|
||||||
|
models.CharField(blank=True, max_length=5000, null=True),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"creation_date",
|
||||||
|
models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"last_fetch_date",
|
||||||
|
models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
("manually_approves_followers", models.NullBooleanField(default=None)),
|
||||||
|
],
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
|
@ -5,13 +5,10 @@ from django.db import migrations
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("federation", "0001_initial")]
|
||||||
('federation', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterUniqueTogether(
|
migrations.AlterUniqueTogether(
|
||||||
name='actor',
|
name="actor", unique_together={("domain", "preferred_username")}
|
||||||
unique_together={('domain', 'preferred_username')},
|
)
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
|
@ -10,7 +10,7 @@ import uuid
|
||||||
def delete_system_actors(apps, schema_editor):
|
def delete_system_actors(apps, schema_editor):
|
||||||
"""Revert site domain and name to default."""
|
"""Revert site domain and name to default."""
|
||||||
Actor = apps.get_model("federation", "Actor")
|
Actor = apps.get_model("federation", "Actor")
|
||||||
Actor.objects.filter(preferred_username__in=['test', 'library']).delete()
|
Actor.objects.filter(preferred_username__in=["test", "library"]).delete()
|
||||||
|
|
||||||
|
|
||||||
def backward(apps, schema_editor):
|
def backward(apps, schema_editor):
|
||||||
|
@ -19,76 +19,168 @@ def backward(apps, schema_editor):
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("federation", "0002_auto_20180403_1620")]
|
||||||
('federation', '0002_auto_20180403_1620'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.RunPython(delete_system_actors, backward),
|
migrations.RunPython(delete_system_actors, backward),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Follow',
|
name="Follow",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True)),
|
"id",
|
||||||
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
|
models.AutoField(
|
||||||
('modification_date', models.DateTimeField(auto_now=True)),
|
auto_created=True,
|
||||||
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emitted_follows', to='federation.Actor')),
|
primary_key=True,
|
||||||
('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_follows', to='federation.Actor')),
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("uuid", models.UUIDField(default=uuid.uuid4, unique=True)),
|
||||||
|
(
|
||||||
|
"creation_date",
|
||||||
|
models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
("modification_date", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"actor",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="emitted_follows",
|
||||||
|
to="federation.Actor",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"target",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="received_follows",
|
||||||
|
to="federation.Actor",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='FollowRequest',
|
name="FollowRequest",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('uuid', models.UUIDField(default=uuid.uuid4, unique=True)),
|
"id",
|
||||||
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
|
models.AutoField(
|
||||||
('modification_date', models.DateTimeField(auto_now=True)),
|
auto_created=True,
|
||||||
('approved', models.NullBooleanField(default=None)),
|
primary_key=True,
|
||||||
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emmited_follow_requests', to='federation.Actor')),
|
serialize=False,
|
||||||
('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_follow_requests', to='federation.Actor')),
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("uuid", models.UUIDField(default=uuid.uuid4, unique=True)),
|
||||||
|
(
|
||||||
|
"creation_date",
|
||||||
|
models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
("modification_date", models.DateTimeField(auto_now=True)),
|
||||||
|
("approved", models.NullBooleanField(default=None)),
|
||||||
|
(
|
||||||
|
"actor",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="emmited_follow_requests",
|
||||||
|
to="federation.Actor",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"target",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="received_follow_requests",
|
||||||
|
to="federation.Actor",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Library',
|
name="Library",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
|
"id",
|
||||||
('modification_date', models.DateTimeField(auto_now=True)),
|
models.AutoField(
|
||||||
('fetched_date', models.DateTimeField(blank=True, null=True)),
|
auto_created=True,
|
||||||
('uuid', models.UUIDField(default=uuid.uuid4)),
|
primary_key=True,
|
||||||
('url', models.URLField()),
|
serialize=False,
|
||||||
('federation_enabled', models.BooleanField()),
|
verbose_name="ID",
|
||||||
('download_files', models.BooleanField()),
|
),
|
||||||
('autoimport', models.BooleanField()),
|
),
|
||||||
('tracks_count', models.PositiveIntegerField(blank=True, null=True)),
|
(
|
||||||
('actor', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='library', to='federation.Actor')),
|
"creation_date",
|
||||||
|
models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
("modification_date", models.DateTimeField(auto_now=True)),
|
||||||
|
("fetched_date", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("uuid", models.UUIDField(default=uuid.uuid4)),
|
||||||
|
("url", models.URLField()),
|
||||||
|
("federation_enabled", models.BooleanField()),
|
||||||
|
("download_files", models.BooleanField()),
|
||||||
|
("autoimport", models.BooleanField()),
|
||||||
|
("tracks_count", models.PositiveIntegerField(blank=True, null=True)),
|
||||||
|
(
|
||||||
|
"actor",
|
||||||
|
models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="library",
|
||||||
|
to="federation.Actor",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='LibraryTrack',
|
name="LibraryTrack",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('url', models.URLField(unique=True)),
|
"id",
|
||||||
('audio_url', models.URLField()),
|
models.AutoField(
|
||||||
('audio_mimetype', models.CharField(max_length=200)),
|
auto_created=True,
|
||||||
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
|
primary_key=True,
|
||||||
('modification_date', models.DateTimeField(auto_now=True)),
|
serialize=False,
|
||||||
('fetched_date', models.DateTimeField(blank=True, null=True)),
|
verbose_name="ID",
|
||||||
('published_date', models.DateTimeField(blank=True, null=True)),
|
),
|
||||||
('artist_name', models.CharField(max_length=500)),
|
),
|
||||||
('album_title', models.CharField(max_length=500)),
|
("url", models.URLField(unique=True)),
|
||||||
('title', models.CharField(max_length=500)),
|
("audio_url", models.URLField()),
|
||||||
('metadata', django.contrib.postgres.fields.jsonb.JSONField(default={}, max_length=10000)),
|
("audio_mimetype", models.CharField(max_length=200)),
|
||||||
('library', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tracks', to='federation.Library')),
|
(
|
||||||
|
"creation_date",
|
||||||
|
models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
("modification_date", models.DateTimeField(auto_now=True)),
|
||||||
|
("fetched_date", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("published_date", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("artist_name", models.CharField(max_length=500)),
|
||||||
|
("album_title", models.CharField(max_length=500)),
|
||||||
|
("title", models.CharField(max_length=500)),
|
||||||
|
(
|
||||||
|
"metadata",
|
||||||
|
django.contrib.postgres.fields.jsonb.JSONField(
|
||||||
|
default={}, max_length=10000
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"library",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="tracks",
|
||||||
|
to="federation.Library",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='actor',
|
model_name="actor",
|
||||||
name='followers',
|
name="followers",
|
||||||
field=models.ManyToManyField(related_name='following', through='federation.Follow', to='federation.Actor'),
|
field=models.ManyToManyField(
|
||||||
|
related_name="following",
|
||||||
|
through="federation.Follow",
|
||||||
|
to="federation.Actor",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterUniqueTogether(
|
migrations.AlterUniqueTogether(
|
||||||
name='follow',
|
name="follow", unique_together={("actor", "target")}
|
||||||
unique_together={('actor', 'target')},
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -6,30 +6,26 @@ import django.db.models.deletion
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("federation", "0003_auto_20180407_1010")]
|
||||||
('federation', '0003_auto_20180407_1010'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.RemoveField(
|
migrations.RemoveField(model_name="followrequest", name="actor"),
|
||||||
model_name='followrequest',
|
migrations.RemoveField(model_name="followrequest", name="target"),
|
||||||
name='actor',
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='followrequest',
|
|
||||||
name='target',
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='follow',
|
model_name="follow",
|
||||||
name='approved',
|
name="approved",
|
||||||
field=models.NullBooleanField(default=None),
|
field=models.NullBooleanField(default=None),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='library',
|
model_name="library",
|
||||||
name='follow',
|
name="follow",
|
||||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='library', to='federation.Follow'),
|
field=models.OneToOneField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="library",
|
||||||
|
to="federation.Follow",
|
||||||
),
|
),
|
||||||
migrations.DeleteModel(
|
|
||||||
name='FollowRequest',
|
|
||||||
),
|
),
|
||||||
|
migrations.DeleteModel(name="FollowRequest"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -8,19 +8,25 @@ import funkwhale_api.federation.models
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("federation", "0004_auto_20180410_2025")]
|
||||||
('federation', '0004_auto_20180410_2025'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='librarytrack',
|
model_name="librarytrack",
|
||||||
name='audio_file',
|
name="audio_file",
|
||||||
field=models.FileField(blank=True, null=True, upload_to=funkwhale_api.federation.models.get_file_path),
|
field=models.FileField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
upload_to=funkwhale_api.federation.models.get_file_path,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='librarytrack',
|
model_name="librarytrack",
|
||||||
name='metadata',
|
name="metadata",
|
||||||
field=django.contrib.postgres.fields.jsonb.JSONField(default={}, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=10000),
|
field=django.contrib.postgres.fields.jsonb.JSONField(
|
||||||
|
default={},
|
||||||
|
encoder=django.core.serializers.json.DjangoJSONEncoder,
|
||||||
|
max_length=10000,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -5,24 +5,20 @@ from django.db import migrations, models
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("federation", "0005_auto_20180413_1723")]
|
||||||
('federation', '0005_auto_20180413_1723'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='library',
|
model_name="library", name="url", field=models.URLField(max_length=500)
|
||||||
name='url',
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="librarytrack",
|
||||||
|
name="audio_url",
|
||||||
field=models.URLField(max_length=500),
|
field=models.URLField(max_length=500),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='librarytrack',
|
model_name="librarytrack",
|
||||||
name='audio_url',
|
name="url",
|
||||||
field=models.URLField(max_length=500),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='librarytrack',
|
|
||||||
name='url',
|
|
||||||
field=models.URLField(max_length=500, unique=True),
|
field=models.URLField(max_length=500, unique=True),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import os
|
import os
|
||||||
import uuid
|
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.postgres.fields import JSONField
|
from django.contrib.postgres.fields import JSONField
|
||||||
|
@ -12,16 +12,16 @@ from funkwhale_api.common import session
|
||||||
from funkwhale_api.music import utils as music_utils
|
from funkwhale_api.music import utils as music_utils
|
||||||
|
|
||||||
TYPE_CHOICES = [
|
TYPE_CHOICES = [
|
||||||
('Person', 'Person'),
|
("Person", "Person"),
|
||||||
('Application', 'Application'),
|
("Application", "Application"),
|
||||||
('Group', 'Group'),
|
("Group", "Group"),
|
||||||
('Organization', 'Organization'),
|
("Organization", "Organization"),
|
||||||
('Service', 'Service'),
|
("Service", "Service"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class Actor(models.Model):
|
class Actor(models.Model):
|
||||||
ap_type = 'Actor'
|
ap_type = "Actor"
|
||||||
|
|
||||||
url = models.URLField(unique=True, max_length=500, db_index=True)
|
url = models.URLField(unique=True, max_length=500, db_index=True)
|
||||||
outbox_url = models.URLField(max_length=500)
|
outbox_url = models.URLField(max_length=500)
|
||||||
|
@ -29,49 +29,41 @@ class Actor(models.Model):
|
||||||
following_url = models.URLField(max_length=500, null=True, blank=True)
|
following_url = models.URLField(max_length=500, null=True, blank=True)
|
||||||
followers_url = models.URLField(max_length=500, null=True, blank=True)
|
followers_url = models.URLField(max_length=500, null=True, blank=True)
|
||||||
shared_inbox_url = models.URLField(max_length=500, null=True, blank=True)
|
shared_inbox_url = models.URLField(max_length=500, null=True, blank=True)
|
||||||
type = models.CharField(
|
type = models.CharField(choices=TYPE_CHOICES, default="Person", max_length=25)
|
||||||
choices=TYPE_CHOICES, default='Person', max_length=25)
|
|
||||||
name = models.CharField(max_length=200, null=True, blank=True)
|
name = models.CharField(max_length=200, null=True, blank=True)
|
||||||
domain = models.CharField(max_length=1000)
|
domain = models.CharField(max_length=1000)
|
||||||
summary = models.CharField(max_length=500, null=True, blank=True)
|
summary = models.CharField(max_length=500, null=True, blank=True)
|
||||||
preferred_username = models.CharField(
|
preferred_username = models.CharField(max_length=200, null=True, blank=True)
|
||||||
max_length=200, null=True, blank=True)
|
|
||||||
public_key = models.CharField(max_length=5000, null=True, blank=True)
|
public_key = models.CharField(max_length=5000, null=True, blank=True)
|
||||||
private_key = models.CharField(max_length=5000, null=True, blank=True)
|
private_key = models.CharField(max_length=5000, null=True, blank=True)
|
||||||
creation_date = models.DateTimeField(default=timezone.now)
|
creation_date = models.DateTimeField(default=timezone.now)
|
||||||
last_fetch_date = models.DateTimeField(
|
last_fetch_date = models.DateTimeField(default=timezone.now)
|
||||||
default=timezone.now)
|
|
||||||
manually_approves_followers = models.NullBooleanField(default=None)
|
manually_approves_followers = models.NullBooleanField(default=None)
|
||||||
followers = models.ManyToManyField(
|
followers = models.ManyToManyField(
|
||||||
to='self',
|
to="self",
|
||||||
symmetrical=False,
|
symmetrical=False,
|
||||||
through='Follow',
|
through="Follow",
|
||||||
through_fields=('target', 'actor'),
|
through_fields=("target", "actor"),
|
||||||
related_name='following',
|
related_name="following",
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ['domain', 'preferred_username']
|
unique_together = ["domain", "preferred_username"]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def webfinger_subject(self):
|
def webfinger_subject(self):
|
||||||
return '{}@{}'.format(
|
return "{}@{}".format(self.preferred_username, settings.FEDERATION_HOSTNAME)
|
||||||
self.preferred_username,
|
|
||||||
settings.FEDERATION_HOSTNAME,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def private_key_id(self):
|
def private_key_id(self):
|
||||||
return '{}#main-key'.format(self.url)
|
return "{}#main-key".format(self.url)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mention_username(self):
|
def mention_username(self):
|
||||||
return '@{}@{}'.format(self.preferred_username, self.domain)
|
return "@{}@{}".format(self.preferred_username, self.domain)
|
||||||
|
|
||||||
def save(self, **kwargs):
|
def save(self, **kwargs):
|
||||||
lowercase_fields = [
|
lowercase_fields = ["domain"]
|
||||||
'domain',
|
|
||||||
]
|
|
||||||
for field in lowercase_fields:
|
for field in lowercase_fields:
|
||||||
v = getattr(self, field, None)
|
v = getattr(self, field, None)
|
||||||
if v:
|
if v:
|
||||||
|
@ -86,58 +78,54 @@ class Actor(models.Model):
|
||||||
@property
|
@property
|
||||||
def is_system(self):
|
def is_system(self):
|
||||||
from . import actors
|
from . import actors
|
||||||
return all([
|
|
||||||
|
return all(
|
||||||
|
[
|
||||||
settings.FEDERATION_HOSTNAME == self.domain,
|
settings.FEDERATION_HOSTNAME == self.domain,
|
||||||
self.preferred_username in actors.SYSTEM_ACTORS
|
self.preferred_username in actors.SYSTEM_ACTORS,
|
||||||
])
|
]
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def system_conf(self):
|
def system_conf(self):
|
||||||
from . import actors
|
from . import actors
|
||||||
|
|
||||||
if self.is_system:
|
if self.is_system:
|
||||||
return actors.SYSTEM_ACTORS[self.preferred_username]
|
return actors.SYSTEM_ACTORS[self.preferred_username]
|
||||||
|
|
||||||
def get_approved_followers(self):
|
def get_approved_followers(self):
|
||||||
follows = self.received_follows.filter(approved=True)
|
follows = self.received_follows.filter(approved=True)
|
||||||
return self.followers.filter(
|
return self.followers.filter(pk__in=follows.values_list("actor", flat=True))
|
||||||
pk__in=follows.values_list('actor', flat=True))
|
|
||||||
|
|
||||||
|
|
||||||
class Follow(models.Model):
|
class Follow(models.Model):
|
||||||
ap_type = 'Follow'
|
ap_type = "Follow"
|
||||||
|
|
||||||
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
|
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
|
||||||
actor = models.ForeignKey(
|
actor = models.ForeignKey(
|
||||||
Actor,
|
Actor, related_name="emitted_follows", on_delete=models.CASCADE
|
||||||
related_name='emitted_follows',
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
)
|
)
|
||||||
target = models.ForeignKey(
|
target = models.ForeignKey(
|
||||||
Actor,
|
Actor, related_name="received_follows", on_delete=models.CASCADE
|
||||||
related_name='received_follows',
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
)
|
)
|
||||||
creation_date = models.DateTimeField(default=timezone.now)
|
creation_date = models.DateTimeField(default=timezone.now)
|
||||||
modification_date = models.DateTimeField(
|
modification_date = models.DateTimeField(auto_now=True)
|
||||||
auto_now=True)
|
|
||||||
approved = models.NullBooleanField(default=None)
|
approved = models.NullBooleanField(default=None)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ['actor', 'target']
|
unique_together = ["actor", "target"]
|
||||||
|
|
||||||
def get_federation_url(self):
|
def get_federation_url(self):
|
||||||
return '{}#follows/{}'.format(self.actor.url, self.uuid)
|
return "{}#follows/{}".format(self.actor.url, self.uuid)
|
||||||
|
|
||||||
|
|
||||||
class Library(models.Model):
|
class Library(models.Model):
|
||||||
creation_date = models.DateTimeField(default=timezone.now)
|
creation_date = models.DateTimeField(default=timezone.now)
|
||||||
modification_date = models.DateTimeField(
|
modification_date = models.DateTimeField(auto_now=True)
|
||||||
auto_now=True)
|
|
||||||
fetched_date = models.DateTimeField(null=True, blank=True)
|
fetched_date = models.DateTimeField(null=True, blank=True)
|
||||||
actor = models.OneToOneField(
|
actor = models.OneToOneField(
|
||||||
Actor,
|
Actor, on_delete=models.CASCADE, related_name="library"
|
||||||
on_delete=models.CASCADE,
|
)
|
||||||
related_name='library')
|
|
||||||
uuid = models.UUIDField(default=uuid.uuid4)
|
uuid = models.UUIDField(default=uuid.uuid4)
|
||||||
url = models.URLField(max_length=500)
|
url = models.URLField(max_length=500)
|
||||||
|
|
||||||
|
@ -149,69 +137,60 @@ class Library(models.Model):
|
||||||
autoimport = models.BooleanField()
|
autoimport = models.BooleanField()
|
||||||
tracks_count = models.PositiveIntegerField(null=True, blank=True)
|
tracks_count = models.PositiveIntegerField(null=True, blank=True)
|
||||||
follow = models.OneToOneField(
|
follow = models.OneToOneField(
|
||||||
Follow,
|
Follow, related_name="library", null=True, blank=True, on_delete=models.SET_NULL
|
||||||
related_name='library',
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_file_path(instance, filename):
|
def get_file_path(instance, filename):
|
||||||
uid = str(uuid.uuid4())
|
uid = str(uuid.uuid4())
|
||||||
chunk_size = 2
|
chunk_size = 2
|
||||||
chunks = [uid[i:i+chunk_size] for i in range(0, len(uid), chunk_size)]
|
chunks = [uid[i : i + chunk_size] for i in range(0, len(uid), chunk_size)]
|
||||||
parts = chunks[:3] + [filename]
|
parts = chunks[:3] + [filename]
|
||||||
return os.path.join('federation_cache', *parts)
|
return os.path.join("federation_cache", *parts)
|
||||||
|
|
||||||
|
|
||||||
class LibraryTrack(models.Model):
|
class LibraryTrack(models.Model):
|
||||||
url = models.URLField(unique=True, max_length=500)
|
url = models.URLField(unique=True, max_length=500)
|
||||||
audio_url = models.URLField(max_length=500)
|
audio_url = models.URLField(max_length=500)
|
||||||
audio_mimetype = models.CharField(max_length=200)
|
audio_mimetype = models.CharField(max_length=200)
|
||||||
audio_file = models.FileField(
|
audio_file = models.FileField(upload_to=get_file_path, null=True, blank=True)
|
||||||
upload_to=get_file_path,
|
|
||||||
null=True,
|
|
||||||
blank=True)
|
|
||||||
|
|
||||||
creation_date = models.DateTimeField(default=timezone.now)
|
creation_date = models.DateTimeField(default=timezone.now)
|
||||||
modification_date = models.DateTimeField(
|
modification_date = models.DateTimeField(auto_now=True)
|
||||||
auto_now=True)
|
|
||||||
fetched_date = models.DateTimeField(null=True, blank=True)
|
fetched_date = models.DateTimeField(null=True, blank=True)
|
||||||
published_date = models.DateTimeField(null=True, blank=True)
|
published_date = models.DateTimeField(null=True, blank=True)
|
||||||
library = models.ForeignKey(
|
library = models.ForeignKey(
|
||||||
Library, related_name='tracks', on_delete=models.CASCADE)
|
Library, related_name="tracks", on_delete=models.CASCADE
|
||||||
|
)
|
||||||
artist_name = models.CharField(max_length=500)
|
artist_name = models.CharField(max_length=500)
|
||||||
album_title = models.CharField(max_length=500)
|
album_title = models.CharField(max_length=500)
|
||||||
title = models.CharField(max_length=500)
|
title = models.CharField(max_length=500)
|
||||||
metadata = JSONField(
|
metadata = JSONField(default={}, max_length=10000, encoder=DjangoJSONEncoder)
|
||||||
default={}, max_length=10000, encoder=DjangoJSONEncoder)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mbid(self):
|
def mbid(self):
|
||||||
try:
|
try:
|
||||||
return self.metadata['recording']['musicbrainz_id']
|
return self.metadata["recording"]["musicbrainz_id"]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def download_audio(self):
|
def download_audio(self):
|
||||||
from . import actors
|
from . import actors
|
||||||
auth = actors.SYSTEM_ACTORS['library'].get_request_auth()
|
|
||||||
|
auth = actors.SYSTEM_ACTORS["library"].get_request_auth()
|
||||||
remote_response = session.get_session().get(
|
remote_response = session.get_session().get(
|
||||||
self.audio_url,
|
self.audio_url,
|
||||||
auth=auth,
|
auth=auth,
|
||||||
stream=True,
|
stream=True,
|
||||||
timeout=20,
|
timeout=20,
|
||||||
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
|
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
|
||||||
headers={
|
headers={"Content-Type": "application/activity+json"},
|
||||||
'Content-Type': 'application/activity+json'
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
with remote_response as r:
|
with remote_response as r:
|
||||||
remote_response.raise_for_status()
|
remote_response.raise_for_status()
|
||||||
extension = music_utils.get_ext_from_type(self.audio_mimetype)
|
extension = music_utils.get_ext_from_type(self.audio_mimetype)
|
||||||
title = ' - '.join([self.title, self.album_title, self.artist_name])
|
title = " - ".join([self.title, self.album_title, self.artist_name])
|
||||||
filename = '{}.{}'.format(title, extension)
|
filename = "{}.{}".format(title, extension)
|
||||||
tmp_file = tempfile.TemporaryFile()
|
tmp_file = tempfile.TemporaryFile()
|
||||||
for chunk in r.iter_content(chunk_size=512):
|
for chunk in r.iter_content(chunk_size=512):
|
||||||
tmp_file.write(chunk)
|
tmp_file.write(chunk)
|
||||||
|
|
|
@ -2,4 +2,4 @@ from rest_framework import parsers
|
||||||
|
|
||||||
|
|
||||||
class ActivityParser(parsers.JSONParser):
|
class ActivityParser(parsers.JSONParser):
|
||||||
media_type = 'application/activity+json'
|
media_type = "application/activity+json"
|
||||||
|
|
|
@ -1,21 +1,19 @@
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from rest_framework.permissions import BasePermission
|
from rest_framework.permissions import BasePermission
|
||||||
|
|
||||||
from funkwhale_api.common import preferences
|
from funkwhale_api.common import preferences
|
||||||
|
|
||||||
from . import actors
|
from . import actors
|
||||||
|
|
||||||
|
|
||||||
class LibraryFollower(BasePermission):
|
class LibraryFollower(BasePermission):
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
if not preferences.get('federation__music_needs_approval'):
|
if not preferences.get("federation__music_needs_approval"):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
actor = getattr(request, 'actor', None)
|
actor = getattr(request, "actor", None)
|
||||||
if actor is None:
|
if actor is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
library = actors.SYSTEM_ACTORS["library"].get_actor_instance()
|
||||||
return library.received_follows.filter(
|
return library.received_follows.filter(approved=True, actor=actor).exists()
|
||||||
approved=True, actor=actor).exists()
|
|
||||||
|
|
|
@ -2,8 +2,8 @@ from rest_framework.renderers import JSONRenderer
|
||||||
|
|
||||||
|
|
||||||
class ActivityPubRenderer(JSONRenderer):
|
class ActivityPubRenderer(JSONRenderer):
|
||||||
media_type = 'application/activity+json'
|
media_type = "application/activity+json"
|
||||||
|
|
||||||
|
|
||||||
class WebfingerRenderer(JSONRenderer):
|
class WebfingerRenderer(JSONRenderer):
|
||||||
media_type = 'application/jrd+json'
|
media_type = "application/jrd+json"
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,18 +1,16 @@
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import requests_http_signature
|
import requests_http_signature
|
||||||
|
|
||||||
from . import exceptions
|
from . import exceptions, utils
|
||||||
from . import utils
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def verify(request, public_key):
|
def verify(request, public_key):
|
||||||
return requests_http_signature.HTTPSignatureAuth.verify(
|
return requests_http_signature.HTTPSignatureAuth.verify(
|
||||||
request,
|
request, key_resolver=lambda **kwargs: public_key, use_auth_header=False
|
||||||
key_resolver=lambda **kwargs: public_key,
|
|
||||||
use_auth_header=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -27,44 +25,37 @@ def verify_django(django_request, public_key):
|
||||||
# with requests_http_signature
|
# with requests_http_signature
|
||||||
headers[h.lower()] = v
|
headers[h.lower()] = v
|
||||||
try:
|
try:
|
||||||
signature = headers['Signature']
|
signature = headers["Signature"]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise exceptions.MissingSignature
|
raise exceptions.MissingSignature
|
||||||
url = 'http://noop{}'.format(django_request.path)
|
url = "http://noop{}".format(django_request.path)
|
||||||
query = django_request.META['QUERY_STRING']
|
query = django_request.META["QUERY_STRING"]
|
||||||
if query:
|
if query:
|
||||||
url += '?{}'.format(query)
|
url += "?{}".format(query)
|
||||||
signature_headers = signature.split('headers="')[1].split('",')[0]
|
signature_headers = signature.split('headers="')[1].split('",')[0]
|
||||||
expected = signature_headers.split(' ')
|
expected = signature_headers.split(" ")
|
||||||
logger.debug('Signature expected headers: %s', expected)
|
logger.debug("Signature expected headers: %s", expected)
|
||||||
for header in expected:
|
for header in expected:
|
||||||
try:
|
try:
|
||||||
headers[header]
|
headers[header]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
logger.debug('Missing header: %s', header)
|
logger.debug("Missing header: %s", header)
|
||||||
request = requests.Request(
|
request = requests.Request(
|
||||||
method=django_request.method,
|
method=django_request.method, url=url, data=django_request.body, headers=headers
|
||||||
url=url,
|
)
|
||||||
data=django_request.body,
|
|
||||||
headers=headers)
|
|
||||||
for h in request.headers.keys():
|
for h in request.headers.keys():
|
||||||
v = request.headers[h]
|
v = request.headers[h]
|
||||||
if v:
|
if v:
|
||||||
request.headers[h] = str(v)
|
request.headers[h] = str(v)
|
||||||
prepared_request = request.prepare()
|
request.prepare()
|
||||||
return verify(request, public_key)
|
return verify(request, public_key)
|
||||||
|
|
||||||
|
|
||||||
def get_auth(private_key, private_key_id):
|
def get_auth(private_key, private_key_id):
|
||||||
return requests_http_signature.HTTPSignatureAuth(
|
return requests_http_signature.HTTPSignatureAuth(
|
||||||
use_auth_header=False,
|
use_auth_header=False,
|
||||||
headers=[
|
headers=["(request-target)", "user-agent", "host", "date", "content-type"],
|
||||||
'(request-target)',
|
algorithm="rsa-sha256",
|
||||||
'user-agent',
|
key=private_key.encode("utf-8"),
|
||||||
'host',
|
|
||||||
'date',
|
|
||||||
'content-type'],
|
|
||||||
algorithm='rsa-sha256',
|
|
||||||
key=private_key.encode('utf-8'),
|
|
||||||
key_id=private_key_id,
|
key_id=private_key_id,
|
||||||
)
|
)
|
||||||
|
|
|
@ -6,114 +6,114 @@ import os
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from requests.exceptions import RequestException
|
|
||||||
from dynamic_preferences.registries import global_preferences_registry
|
from dynamic_preferences.registries import global_preferences_registry
|
||||||
|
from requests.exceptions import RequestException
|
||||||
|
|
||||||
from funkwhale_api.common import session
|
from funkwhale_api.common import session
|
||||||
from funkwhale_api.history.models import Listening
|
|
||||||
from funkwhale_api.taskapp import celery
|
from funkwhale_api.taskapp import celery
|
||||||
|
|
||||||
from . import actors
|
from . import actors
|
||||||
from . import library as lb
|
from . import library as lb
|
||||||
from . import models
|
from . import models, signing
|
||||||
from . import signing
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@celery.app.task(
|
@celery.app.task(
|
||||||
name='federation.send',
|
name="federation.send",
|
||||||
autoretry_for=[RequestException],
|
autoretry_for=[RequestException],
|
||||||
retry_backoff=30,
|
retry_backoff=30,
|
||||||
max_retries=5)
|
max_retries=5,
|
||||||
@celery.require_instance(models.Actor, 'actor')
|
)
|
||||||
|
@celery.require_instance(models.Actor, "actor")
|
||||||
def send(activity, actor, to):
|
def send(activity, actor, to):
|
||||||
logger.info('Preparing activity delivery to %s', to)
|
logger.info("Preparing activity delivery to %s", to)
|
||||||
auth = signing.get_auth(
|
auth = signing.get_auth(actor.private_key, actor.private_key_id)
|
||||||
actor.private_key, actor.private_key_id)
|
|
||||||
for url in to:
|
for url in to:
|
||||||
recipient_actor = actors.get_actor(url)
|
recipient_actor = actors.get_actor(url)
|
||||||
logger.debug('delivering to %s', recipient_actor.inbox_url)
|
logger.debug("delivering to %s", recipient_actor.inbox_url)
|
||||||
logger.debug('activity content: %s', json.dumps(activity))
|
logger.debug("activity content: %s", json.dumps(activity))
|
||||||
response = session.get_session().post(
|
response = session.get_session().post(
|
||||||
auth=auth,
|
auth=auth,
|
||||||
json=activity,
|
json=activity,
|
||||||
url=recipient_actor.inbox_url,
|
url=recipient_actor.inbox_url,
|
||||||
timeout=5,
|
timeout=5,
|
||||||
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
|
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
|
||||||
headers={
|
headers={"Content-Type": "application/activity+json"},
|
||||||
'Content-Type': 'application/activity+json'
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
logger.debug('Remote answered with %s', response.status_code)
|
logger.debug("Remote answered with %s", response.status_code)
|
||||||
|
|
||||||
|
|
||||||
@celery.app.task(
|
@celery.app.task(
|
||||||
name='federation.scan_library',
|
name="federation.scan_library",
|
||||||
autoretry_for=[RequestException],
|
autoretry_for=[RequestException],
|
||||||
retry_backoff=30,
|
retry_backoff=30,
|
||||||
max_retries=5)
|
max_retries=5,
|
||||||
@celery.require_instance(models.Library, 'library')
|
)
|
||||||
|
@celery.require_instance(models.Library, "library")
|
||||||
def scan_library(library, until=None):
|
def scan_library(library, until=None):
|
||||||
if not library.federation_enabled:
|
if not library.federation_enabled:
|
||||||
return
|
return
|
||||||
|
|
||||||
data = lb.get_library_data(library.url)
|
data = lb.get_library_data(library.url)
|
||||||
scan_library_page.delay(
|
scan_library_page.delay(library_id=library.id, page_url=data["first"], until=until)
|
||||||
library_id=library.id, page_url=data['first'], until=until)
|
|
||||||
library.fetched_date = timezone.now()
|
library.fetched_date = timezone.now()
|
||||||
library.tracks_count = data['totalItems']
|
library.tracks_count = data["totalItems"]
|
||||||
library.save(update_fields=['fetched_date', 'tracks_count'])
|
library.save(update_fields=["fetched_date", "tracks_count"])
|
||||||
|
|
||||||
|
|
||||||
@celery.app.task(
|
@celery.app.task(
|
||||||
name='federation.scan_library_page',
|
name="federation.scan_library_page",
|
||||||
autoretry_for=[RequestException],
|
autoretry_for=[RequestException],
|
||||||
retry_backoff=30,
|
retry_backoff=30,
|
||||||
max_retries=5)
|
max_retries=5,
|
||||||
@celery.require_instance(models.Library, 'library')
|
)
|
||||||
|
@celery.require_instance(models.Library, "library")
|
||||||
def scan_library_page(library, page_url, until=None):
|
def scan_library_page(library, page_url, until=None):
|
||||||
if not library.federation_enabled:
|
if not library.federation_enabled:
|
||||||
return
|
return
|
||||||
|
|
||||||
data = lb.get_library_page(library, page_url)
|
data = lb.get_library_page(library, page_url)
|
||||||
lts = []
|
lts = []
|
||||||
for item_serializer in data['items']:
|
for item_serializer in data["items"]:
|
||||||
item_date = item_serializer.validated_data['published']
|
item_date = item_serializer.validated_data["published"]
|
||||||
if until and item_date < until:
|
if until and item_date < until:
|
||||||
return
|
return
|
||||||
lts.append(item_serializer.save())
|
lts.append(item_serializer.save())
|
||||||
|
|
||||||
next_page = data.get('next')
|
next_page = data.get("next")
|
||||||
if next_page and next_page != page_url:
|
if next_page and next_page != page_url:
|
||||||
scan_library_page.delay(library_id=library.id, page_url=next_page)
|
scan_library_page.delay(library_id=library.id, page_url=next_page)
|
||||||
|
|
||||||
|
|
||||||
@celery.app.task(name='federation.clean_music_cache')
|
@celery.app.task(name="federation.clean_music_cache")
|
||||||
def clean_music_cache():
|
def clean_music_cache():
|
||||||
preferences = global_preferences_registry.manager()
|
preferences = global_preferences_registry.manager()
|
||||||
delay = preferences['federation__music_cache_duration']
|
delay = preferences["federation__music_cache_duration"]
|
||||||
if delay < 1:
|
if delay < 1:
|
||||||
return # cache clearing disabled
|
return # cache clearing disabled
|
||||||
limit = timezone.now() - datetime.timedelta(minutes=delay)
|
limit = timezone.now() - datetime.timedelta(minutes=delay)
|
||||||
|
|
||||||
candidates = models.LibraryTrack.objects.filter(
|
candidates = (
|
||||||
Q(audio_file__isnull=False) & (
|
models.LibraryTrack.objects.filter(
|
||||||
Q(local_track_file__accessed_date__lt=limit) |
|
Q(audio_file__isnull=False)
|
||||||
Q(local_track_file__accessed_date=None)
|
& (
|
||||||
|
Q(local_track_file__accessed_date__lt=limit)
|
||||||
|
| Q(local_track_file__accessed_date=None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.exclude(audio_file="")
|
||||||
|
.only("audio_file", "id")
|
||||||
)
|
)
|
||||||
).exclude(audio_file='').only('audio_file', 'id')
|
|
||||||
for lt in candidates:
|
for lt in candidates:
|
||||||
lt.audio_file.delete()
|
lt.audio_file.delete()
|
||||||
|
|
||||||
# we also delete orphaned files, if any
|
# we also delete orphaned files, if any
|
||||||
storage = models.LibraryTrack._meta.get_field('audio_file').storage
|
storage = models.LibraryTrack._meta.get_field("audio_file").storage
|
||||||
files = get_files(storage, 'federation_cache')
|
files = get_files(storage, "federation_cache")
|
||||||
existing = models.LibraryTrack.objects.filter(audio_file__in=files)
|
existing = models.LibraryTrack.objects.filter(audio_file__in=files)
|
||||||
missing = set(files) - set(existing.values_list('audio_file', flat=True))
|
missing = set(files) - set(existing.values_list("audio_file", flat=True))
|
||||||
for m in missing:
|
for m in missing:
|
||||||
storage.delete(m)
|
storage.delete(m)
|
||||||
|
|
||||||
|
@ -124,12 +124,9 @@ def get_files(storage, *parts):
|
||||||
in a given directory using django's storage.
|
in a given directory using django's storage.
|
||||||
"""
|
"""
|
||||||
if not parts:
|
if not parts:
|
||||||
raise ValueError('Missing path')
|
raise ValueError("Missing path")
|
||||||
|
|
||||||
dirs, files = storage.listdir(os.path.join(*parts))
|
dirs, files = storage.listdir(os.path.join(*parts))
|
||||||
for dir in dirs:
|
for dir in dirs:
|
||||||
files += get_files(storage, *(list(parts) + [dir]))
|
files += get_files(storage, *(list(parts) + [dir]))
|
||||||
return [
|
return [os.path.join(parts[-1], path) for path in files]
|
||||||
os.path.join(parts[-1], path)
|
|
||||||
for path in files
|
|
||||||
]
|
|
||||||
|
|
|
@ -1,24 +1,16 @@
|
||||||
from django.conf.urls import include, url
|
from django.conf.urls import include, url
|
||||||
|
|
||||||
from rest_framework import routers
|
from rest_framework import routers
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
router = routers.SimpleRouter(trailing_slash=False)
|
router = routers.SimpleRouter(trailing_slash=False)
|
||||||
music_router = routers.SimpleRouter(trailing_slash=False)
|
music_router = routers.SimpleRouter(trailing_slash=False)
|
||||||
router.register(
|
router.register(
|
||||||
r'federation/instance/actors',
|
r"federation/instance/actors", views.InstanceActorViewSet, "instance-actors"
|
||||||
views.InstanceActorViewSet,
|
|
||||||
'instance-actors')
|
|
||||||
router.register(
|
|
||||||
r'.well-known',
|
|
||||||
views.WellKnownViewSet,
|
|
||||||
'well-known')
|
|
||||||
|
|
||||||
music_router.register(
|
|
||||||
r'files',
|
|
||||||
views.MusicFilesViewSet,
|
|
||||||
'files',
|
|
||||||
)
|
)
|
||||||
|
router.register(r".well-known", views.WellKnownViewSet, "well-known")
|
||||||
|
|
||||||
|
music_router.register(r"files", views.MusicFilesViewSet, "files")
|
||||||
urlpatterns = router.urls + [
|
urlpatterns = router.urls + [
|
||||||
url('federation/music/', include((music_router.urls, 'music'), namespace='music'))
|
url("federation/music/", include((music_router.urls, "music"), namespace="music"))
|
||||||
]
|
]
|
||||||
|
|
|
@ -6,10 +6,10 @@ def full_url(path):
|
||||||
Given a relative path, return a full url usable for federation purpose
|
Given a relative path, return a full url usable for federation purpose
|
||||||
"""
|
"""
|
||||||
root = settings.FUNKWHALE_URL
|
root = settings.FUNKWHALE_URL
|
||||||
if path.startswith('/') and root.endswith('/'):
|
if path.startswith("/") and root.endswith("/"):
|
||||||
return root + path[1:]
|
return root + path[1:]
|
||||||
elif not path.startswith('/') and not root.endswith('/'):
|
elif not path.startswith("/") and not root.endswith("/"):
|
||||||
return root + '/' + path
|
return root + "/" + path
|
||||||
else:
|
else:
|
||||||
return root + path
|
return root + path
|
||||||
|
|
||||||
|
@ -19,17 +19,14 @@ def clean_wsgi_headers(raw_headers):
|
||||||
Convert WSGI headers from CONTENT_TYPE to Content-Type notation
|
Convert WSGI headers from CONTENT_TYPE to Content-Type notation
|
||||||
"""
|
"""
|
||||||
cleaned = {}
|
cleaned = {}
|
||||||
non_prefixed = [
|
non_prefixed = ["content_type", "content_length"]
|
||||||
'content_type',
|
|
||||||
'content_length',
|
|
||||||
]
|
|
||||||
for raw_header, value in raw_headers.items():
|
for raw_header, value in raw_headers.items():
|
||||||
h = raw_header.lower()
|
h = raw_header.lower()
|
||||||
if not h.startswith('http_') and h not in non_prefixed:
|
if not h.startswith("http_") and h not in non_prefixed:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
words = h.replace('http_', '', 1).split('_')
|
words = h.replace("http_", "", 1).split("_")
|
||||||
cleaned_header = '-'.join([w.capitalize() for w in words])
|
cleaned_header = "-".join([w.capitalize() for w in words])
|
||||||
cleaned[cleaned_header] = value
|
cleaned[cleaned_header] = value
|
||||||
|
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
|
@ -1,55 +1,47 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
|
||||||
from django.core import paginator
|
from django.core import paginator
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse, Http404
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from rest_framework import mixins, response, viewsets
|
||||||
from rest_framework import mixins
|
from rest_framework.decorators import detail_route, list_route
|
||||||
from rest_framework import permissions as rest_permissions
|
|
||||||
from rest_framework import response
|
|
||||||
from rest_framework import views
|
|
||||||
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 preferences
|
||||||
from funkwhale_api.common import utils as funkwhale_utils
|
|
||||||
from funkwhale_api.music import models as music_models
|
from funkwhale_api.music import models as music_models
|
||||||
from funkwhale_api.users.permissions import HasUserPermission
|
from funkwhale_api.users.permissions import HasUserPermission
|
||||||
|
|
||||||
from . import activity
|
from . import (
|
||||||
from . import actors
|
actors,
|
||||||
from . import authentication
|
authentication,
|
||||||
from . import filters
|
filters,
|
||||||
from . import library
|
library,
|
||||||
from . import models
|
models,
|
||||||
from . import permissions
|
permissions,
|
||||||
from . import renderers
|
renderers,
|
||||||
from . import serializers
|
serializers,
|
||||||
from . import tasks
|
tasks,
|
||||||
from . import utils
|
utils,
|
||||||
from . import webfinger
|
webfinger,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FederationMixin(object):
|
class FederationMixin(object):
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
if not preferences.get('federation__enabled'):
|
if not preferences.get("federation__enabled"):
|
||||||
return HttpResponse(status=405)
|
return HttpResponse(status=405)
|
||||||
return super().dispatch(request, *args, **kwargs)
|
return super().dispatch(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
|
class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
|
||||||
lookup_field = 'actor'
|
lookup_field = "actor"
|
||||||
lookup_value_regex = '[a-z]*'
|
lookup_value_regex = "[a-z]*"
|
||||||
authentication_classes = [
|
authentication_classes = [authentication.SignatureAuthentication]
|
||||||
authentication.SignatureAuthentication]
|
|
||||||
permission_classes = []
|
permission_classes = []
|
||||||
renderer_classes = [renderers.ActivityPubRenderer]
|
renderer_classes = [renderers.ActivityPubRenderer]
|
||||||
|
|
||||||
def get_object(self):
|
def get_object(self):
|
||||||
try:
|
try:
|
||||||
return actors.SYSTEM_ACTORS[self.kwargs['actor']]
|
return actors.SYSTEM_ACTORS[self.kwargs["actor"]]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise Http404
|
raise Http404
|
||||||
|
|
||||||
|
@ -59,27 +51,23 @@ class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
|
||||||
data = actor.system_conf.serialize()
|
data = actor.system_conf.serialize()
|
||||||
return response.Response(data, status=200)
|
return response.Response(data, status=200)
|
||||||
|
|
||||||
@detail_route(methods=['get', 'post'])
|
@detail_route(methods=["get", "post"])
|
||||||
def inbox(self, request, *args, **kwargs):
|
def inbox(self, request, *args, **kwargs):
|
||||||
system_actor = self.get_object()
|
system_actor = self.get_object()
|
||||||
handler = getattr(system_actor, '{}_inbox'.format(
|
handler = getattr(system_actor, "{}_inbox".format(request.method.lower()))
|
||||||
request.method.lower()
|
|
||||||
))
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = handler(request.data, actor=request.actor)
|
handler(request.data, actor=request.actor)
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
return response.Response(status=405)
|
return response.Response(status=405)
|
||||||
return response.Response({}, status=200)
|
return response.Response({}, status=200)
|
||||||
|
|
||||||
@detail_route(methods=['get', 'post'])
|
@detail_route(methods=["get", "post"])
|
||||||
def outbox(self, request, *args, **kwargs):
|
def outbox(self, request, *args, **kwargs):
|
||||||
system_actor = self.get_object()
|
system_actor = self.get_object()
|
||||||
handler = getattr(system_actor, '{}_outbox'.format(
|
handler = getattr(system_actor, "{}_outbox".format(request.method.lower()))
|
||||||
request.method.lower()
|
|
||||||
))
|
|
||||||
try:
|
try:
|
||||||
data = handler(request.data, actor=request.actor)
|
handler(request.data, actor=request.actor)
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
return response.Response(status=405)
|
return response.Response(status=405)
|
||||||
return response.Response({}, status=200)
|
return response.Response({}, status=200)
|
||||||
|
@ -90,45 +78,36 @@ class WellKnownViewSet(viewsets.GenericViewSet):
|
||||||
permission_classes = []
|
permission_classes = []
|
||||||
renderer_classes = [renderers.JSONRenderer, renderers.WebfingerRenderer]
|
renderer_classes = [renderers.JSONRenderer, renderers.WebfingerRenderer]
|
||||||
|
|
||||||
@list_route(methods=['get'])
|
@list_route(methods=["get"])
|
||||||
def nodeinfo(self, request, *args, **kwargs):
|
def nodeinfo(self, request, *args, **kwargs):
|
||||||
if not preferences.get('instance__nodeinfo_enabled'):
|
if not preferences.get("instance__nodeinfo_enabled"):
|
||||||
return HttpResponse(status=404)
|
return HttpResponse(status=404)
|
||||||
data = {
|
data = {
|
||||||
'links': [
|
"links": [
|
||||||
{
|
{
|
||||||
'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.0',
|
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
|
||||||
'href': utils.full_url(
|
"href": utils.full_url(reverse("api:v1:instance:nodeinfo-2.0")),
|
||||||
reverse('api:v1:instance:nodeinfo-2.0')
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
return response.Response(data)
|
return response.Response(data)
|
||||||
|
|
||||||
@list_route(methods=['get'])
|
@list_route(methods=["get"])
|
||||||
def webfinger(self, request, *args, **kwargs):
|
def webfinger(self, request, *args, **kwargs):
|
||||||
if not preferences.get('federation__enabled'):
|
if not preferences.get("federation__enabled"):
|
||||||
return HttpResponse(status=405)
|
return HttpResponse(status=405)
|
||||||
try:
|
try:
|
||||||
resource_type, resource = webfinger.clean_resource(
|
resource_type, resource = webfinger.clean_resource(request.GET["resource"])
|
||||||
request.GET['resource'])
|
cleaner = getattr(webfinger, "clean_{}".format(resource_type))
|
||||||
cleaner = getattr(webfinger, 'clean_{}'.format(resource_type))
|
|
||||||
result = cleaner(resource)
|
result = cleaner(resource)
|
||||||
except forms.ValidationError as e:
|
except forms.ValidationError as e:
|
||||||
return response.Response({
|
return response.Response({"errors": {"resource": e.message}}, status=400)
|
||||||
'errors': {
|
|
||||||
'resource': e.message
|
|
||||||
}
|
|
||||||
}, status=400)
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return response.Response({
|
return response.Response(
|
||||||
'errors': {
|
{"errors": {"resource": "This field is required"}}, status=400
|
||||||
'resource': 'This field is required',
|
)
|
||||||
}
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
handler = getattr(self, 'handler_{}'.format(resource_type))
|
handler = getattr(self, "handler_{}".format(resource_type))
|
||||||
data = handler(result)
|
data = handler(result)
|
||||||
|
|
||||||
return response.Response(data)
|
return response.Response(data)
|
||||||
|
@ -140,46 +119,43 @@ class WellKnownViewSet(viewsets.GenericViewSet):
|
||||||
|
|
||||||
|
|
||||||
class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet):
|
class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet):
|
||||||
authentication_classes = [
|
authentication_classes = [authentication.SignatureAuthentication]
|
||||||
authentication.SignatureAuthentication]
|
|
||||||
permission_classes = [permissions.LibraryFollower]
|
permission_classes = [permissions.LibraryFollower]
|
||||||
renderer_classes = [renderers.ActivityPubRenderer]
|
renderer_classes = [renderers.ActivityPubRenderer]
|
||||||
|
|
||||||
def list(self, request, *args, **kwargs):
|
def list(self, request, *args, **kwargs):
|
||||||
page = request.GET.get('page')
|
page = request.GET.get("page")
|
||||||
library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
library = actors.SYSTEM_ACTORS["library"].get_actor_instance()
|
||||||
qs = music_models.TrackFile.objects.order_by(
|
qs = (
|
||||||
'-creation_date'
|
music_models.TrackFile.objects.order_by("-creation_date")
|
||||||
).select_related(
|
.select_related("track__artist", "track__album__artist")
|
||||||
'track__artist',
|
.filter(library_track__isnull=True)
|
||||||
'track__album__artist'
|
)
|
||||||
).filter(library_track__isnull=True)
|
|
||||||
if page is None:
|
if page is None:
|
||||||
conf = {
|
conf = {
|
||||||
'id': utils.full_url(reverse('federation:music:files-list')),
|
"id": utils.full_url(reverse("federation:music:files-list")),
|
||||||
'page_size': preferences.get(
|
"page_size": preferences.get("federation__collection_page_size"),
|
||||||
'federation__collection_page_size'),
|
"items": qs,
|
||||||
'items': qs,
|
"item_serializer": serializers.AudioSerializer,
|
||||||
'item_serializer': serializers.AudioSerializer,
|
"actor": library,
|
||||||
'actor': library,
|
|
||||||
}
|
}
|
||||||
serializer = serializers.PaginatedCollectionSerializer(conf)
|
serializer = serializers.PaginatedCollectionSerializer(conf)
|
||||||
data = serializer.data
|
data = serializer.data
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
page_number = int(page)
|
page_number = int(page)
|
||||||
except:
|
except Exception:
|
||||||
return response.Response(
|
return response.Response({"page": ["Invalid page number"]}, status=400)
|
||||||
{'page': ['Invalid page number']}, status=400)
|
|
||||||
p = paginator.Paginator(
|
p = paginator.Paginator(
|
||||||
qs, preferences.get('federation__collection_page_size'))
|
qs, preferences.get("federation__collection_page_size")
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
page = p.page(page_number)
|
page = p.page(page_number)
|
||||||
conf = {
|
conf = {
|
||||||
'id': utils.full_url(reverse('federation:music:files-list')),
|
"id": utils.full_url(reverse("federation:music:files-list")),
|
||||||
'page': page,
|
"page": page,
|
||||||
'item_serializer': serializers.AudioSerializer,
|
"item_serializer": serializers.AudioSerializer,
|
||||||
'actor': library,
|
"actor": library,
|
||||||
}
|
}
|
||||||
serializer = serializers.CollectionPageSerializer(conf)
|
serializer = serializers.CollectionPageSerializer(conf)
|
||||||
data = serializer.data
|
data = serializer.data
|
||||||
|
@ -193,131 +169,109 @@ class LibraryViewSet(
|
||||||
mixins.RetrieveModelMixin,
|
mixins.RetrieveModelMixin,
|
||||||
mixins.UpdateModelMixin,
|
mixins.UpdateModelMixin,
|
||||||
mixins.ListModelMixin,
|
mixins.ListModelMixin,
|
||||||
viewsets.GenericViewSet):
|
viewsets.GenericViewSet,
|
||||||
|
):
|
||||||
permission_classes = (HasUserPermission,)
|
permission_classes = (HasUserPermission,)
|
||||||
required_permissions = ['federation']
|
required_permissions = ["federation"]
|
||||||
queryset = models.Library.objects.all().select_related(
|
queryset = models.Library.objects.all().select_related("actor", "follow")
|
||||||
'actor',
|
lookup_field = "uuid"
|
||||||
'follow',
|
|
||||||
)
|
|
||||||
lookup_field = 'uuid'
|
|
||||||
filter_class = filters.LibraryFilter
|
filter_class = filters.LibraryFilter
|
||||||
serializer_class = serializers.APILibrarySerializer
|
serializer_class = serializers.APILibrarySerializer
|
||||||
ordering_fields = (
|
ordering_fields = (
|
||||||
'id',
|
"id",
|
||||||
'creation_date',
|
"creation_date",
|
||||||
'fetched_date',
|
"fetched_date",
|
||||||
'actor__domain',
|
"actor__domain",
|
||||||
'tracks_count',
|
"tracks_count",
|
||||||
)
|
)
|
||||||
|
|
||||||
@list_route(methods=['get'])
|
@list_route(methods=["get"])
|
||||||
def fetch(self, request, *args, **kwargs):
|
def fetch(self, request, *args, **kwargs):
|
||||||
account = request.GET.get('account')
|
account = request.GET.get("account")
|
||||||
if not account:
|
if not account:
|
||||||
return response.Response(
|
return response.Response({"account": "This field is mandatory"}, status=400)
|
||||||
{'account': 'This field is mandatory'}, status=400)
|
|
||||||
|
|
||||||
data = library.scan_from_account_name(account)
|
data = library.scan_from_account_name(account)
|
||||||
return response.Response(data)
|
return response.Response(data)
|
||||||
|
|
||||||
@detail_route(methods=['post'])
|
@detail_route(methods=["post"])
|
||||||
def scan(self, request, *args, **kwargs):
|
def scan(self, request, *args, **kwargs):
|
||||||
library = self.get_object()
|
library = self.get_object()
|
||||||
serializer = serializers.APILibraryScanSerializer(
|
serializer = serializers.APILibraryScanSerializer(data=request.data)
|
||||||
data=request.data
|
|
||||||
)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
result = tasks.scan_library.delay(
|
result = tasks.scan_library.delay(
|
||||||
library_id=library.pk,
|
library_id=library.pk, until=serializer.validated_data.get("until")
|
||||||
until=serializer.validated_data.get('until')
|
|
||||||
)
|
)
|
||||||
return response.Response({'task': result.id})
|
return response.Response({"task": result.id})
|
||||||
|
|
||||||
@list_route(methods=['get'])
|
@list_route(methods=["get"])
|
||||||
def following(self, request, *args, **kwargs):
|
def following(self, request, *args, **kwargs):
|
||||||
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
|
||||||
queryset = models.Follow.objects.filter(
|
queryset = (
|
||||||
actor=library_actor
|
models.Follow.objects.filter(actor=library_actor)
|
||||||
).select_related(
|
.select_related("actor", "target")
|
||||||
'actor',
|
.order_by("-creation_date")
|
||||||
'target',
|
)
|
||||||
).order_by('-creation_date')
|
|
||||||
filterset = filters.FollowFilter(request.GET, queryset=queryset)
|
filterset = filters.FollowFilter(request.GET, queryset=queryset)
|
||||||
final_qs = filterset.qs
|
final_qs = filterset.qs
|
||||||
serializer = serializers.APIFollowSerializer(final_qs, many=True)
|
serializer = serializers.APIFollowSerializer(final_qs, many=True)
|
||||||
data = {
|
data = {"results": serializer.data, "count": len(final_qs)}
|
||||||
'results': serializer.data,
|
|
||||||
'count': len(final_qs),
|
|
||||||
}
|
|
||||||
return response.Response(data)
|
return response.Response(data)
|
||||||
|
|
||||||
@list_route(methods=['get', 'patch'])
|
@list_route(methods=["get", "patch"])
|
||||||
def followers(self, request, *args, **kwargs):
|
def followers(self, request, *args, **kwargs):
|
||||||
if request.method.lower() == 'patch':
|
if request.method.lower() == "patch":
|
||||||
serializer = serializers.APILibraryFollowUpdateSerializer(
|
serializer = serializers.APILibraryFollowUpdateSerializer(data=request.data)
|
||||||
data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
follow = serializer.save()
|
follow = serializer.save()
|
||||||
return response.Response(
|
return response.Response(serializers.APIFollowSerializer(follow).data)
|
||||||
serializers.APIFollowSerializer(follow).data
|
|
||||||
)
|
|
||||||
|
|
||||||
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
|
||||||
queryset = models.Follow.objects.filter(
|
queryset = (
|
||||||
target=library_actor
|
models.Follow.objects.filter(target=library_actor)
|
||||||
).select_related(
|
.select_related("actor", "target")
|
||||||
'actor',
|
.order_by("-creation_date")
|
||||||
'target',
|
)
|
||||||
).order_by('-creation_date')
|
|
||||||
filterset = filters.FollowFilter(request.GET, queryset=queryset)
|
filterset = filters.FollowFilter(request.GET, queryset=queryset)
|
||||||
final_qs = filterset.qs
|
final_qs = filterset.qs
|
||||||
serializer = serializers.APIFollowSerializer(final_qs, many=True)
|
serializer = serializers.APIFollowSerializer(final_qs, many=True)
|
||||||
data = {
|
data = {"results": serializer.data, "count": len(final_qs)}
|
||||||
'results': serializer.data,
|
|
||||||
'count': len(final_qs),
|
|
||||||
}
|
|
||||||
return response.Response(data)
|
return response.Response(data)
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
serializer = serializers.APILibraryCreateSerializer(data=request.data)
|
serializer = serializers.APILibraryCreateSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
library = serializer.save()
|
serializer.save()
|
||||||
return response.Response(serializer.data, status=201)
|
return response.Response(serializer.data, status=201)
|
||||||
|
|
||||||
|
|
||||||
class LibraryTrackViewSet(
|
class LibraryTrackViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
|
||||||
mixins.ListModelMixin,
|
|
||||||
viewsets.GenericViewSet):
|
|
||||||
permission_classes = (HasUserPermission,)
|
permission_classes = (HasUserPermission,)
|
||||||
required_permissions = ['federation']
|
required_permissions = ["federation"]
|
||||||
queryset = models.LibraryTrack.objects.all().select_related(
|
queryset = (
|
||||||
'library__actor',
|
models.LibraryTrack.objects.all()
|
||||||
'library__follow',
|
.select_related("library__actor", "library__follow", "local_track_file")
|
||||||
'local_track_file',
|
.prefetch_related("import_jobs")
|
||||||
).prefetch_related('import_jobs')
|
)
|
||||||
filter_class = filters.LibraryTrackFilter
|
filter_class = filters.LibraryTrackFilter
|
||||||
serializer_class = serializers.APILibraryTrackSerializer
|
serializer_class = serializers.APILibraryTrackSerializer
|
||||||
ordering_fields = (
|
ordering_fields = (
|
||||||
'id',
|
"id",
|
||||||
'artist_name',
|
"artist_name",
|
||||||
'title',
|
"title",
|
||||||
'album_title',
|
"album_title",
|
||||||
'creation_date',
|
"creation_date",
|
||||||
'modification_date',
|
"modification_date",
|
||||||
'fetched_date',
|
"fetched_date",
|
||||||
'published_date',
|
"published_date",
|
||||||
)
|
)
|
||||||
|
|
||||||
@list_route(methods=['post'])
|
@list_route(methods=["post"])
|
||||||
def action(self, request, *args, **kwargs):
|
def action(self, request, *args, **kwargs):
|
||||||
queryset = models.LibraryTrack.objects.filter(
|
queryset = models.LibraryTrack.objects.filter(local_track_file__isnull=True)
|
||||||
local_track_file__isnull=True)
|
|
||||||
serializer = serializers.LibraryTrackActionSerializer(
|
serializer = serializers.LibraryTrackActionSerializer(
|
||||||
request.data,
|
request.data, queryset=queryset, context={"submitted_by": request.user}
|
||||||
queryset=queryset,
|
|
||||||
context={'submitted_by': request.user}
|
|
||||||
)
|
)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
result = serializer.save()
|
result = serializer.save()
|
||||||
|
|
|
@ -1,43 +1,39 @@
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from funkwhale_api.common import session
|
from funkwhale_api.common import session
|
||||||
|
|
||||||
from . import actors
|
from . import actors, serializers
|
||||||
from . import utils
|
|
||||||
from . import serializers
|
|
||||||
|
|
||||||
VALID_RESOURCE_TYPES = ['acct']
|
VALID_RESOURCE_TYPES = ["acct"]
|
||||||
|
|
||||||
|
|
||||||
def clean_resource(resource_string):
|
def clean_resource(resource_string):
|
||||||
if not resource_string:
|
if not resource_string:
|
||||||
raise forms.ValidationError('Invalid resource string')
|
raise forms.ValidationError("Invalid resource string")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resource_type, resource = resource_string.split(':', 1)
|
resource_type, resource = resource_string.split(":", 1)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise forms.ValidationError('Missing webfinger resource type')
|
raise forms.ValidationError("Missing webfinger resource type")
|
||||||
|
|
||||||
if resource_type not in VALID_RESOURCE_TYPES:
|
if resource_type not in VALID_RESOURCE_TYPES:
|
||||||
raise forms.ValidationError('Invalid webfinger resource type')
|
raise forms.ValidationError("Invalid webfinger resource type")
|
||||||
|
|
||||||
return resource_type, resource
|
return resource_type, resource
|
||||||
|
|
||||||
|
|
||||||
def clean_acct(acct_string, ensure_local=True):
|
def clean_acct(acct_string, ensure_local=True):
|
||||||
try:
|
try:
|
||||||
username, hostname = acct_string.split('@')
|
username, hostname = acct_string.split("@")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise forms.ValidationError('Invalid format')
|
raise forms.ValidationError("Invalid format")
|
||||||
|
|
||||||
if ensure_local and hostname.lower() != settings.FEDERATION_HOSTNAME:
|
if ensure_local and hostname.lower() != settings.FEDERATION_HOSTNAME:
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError("Invalid hostname {}".format(hostname))
|
||||||
'Invalid hostname {}'.format(hostname))
|
|
||||||
|
|
||||||
if ensure_local and username not in actors.SYSTEM_ACTORS:
|
if ensure_local and username not in actors.SYSTEM_ACTORS:
|
||||||
raise forms.ValidationError('Invalid username')
|
raise forms.ValidationError("Invalid username")
|
||||||
|
|
||||||
return username, hostname
|
return username, hostname
|
||||||
|
|
||||||
|
@ -45,12 +41,12 @@ def clean_acct(acct_string, ensure_local=True):
|
||||||
def get_resource(resource_string):
|
def get_resource(resource_string):
|
||||||
resource_type, resource = clean_resource(resource_string)
|
resource_type, resource = clean_resource(resource_string)
|
||||||
username, hostname = clean_acct(resource, ensure_local=False)
|
username, hostname = clean_acct(resource, ensure_local=False)
|
||||||
url = 'https://{}/.well-known/webfinger?resource={}'.format(
|
url = "https://{}/.well-known/webfinger?resource={}".format(
|
||||||
hostname, resource_string)
|
hostname, resource_string
|
||||||
|
)
|
||||||
response = session.get_session().get(
|
response = session.get_session().get(
|
||||||
url,
|
url, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, timeout=5
|
||||||
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
|
)
|
||||||
timeout=5)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
serializer = serializers.ActorWebfingerSerializer(data=response.json())
|
serializer = serializers.ActorWebfingerSerializer(data=response.json())
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
|
@ -1,19 +1,16 @@
|
||||||
from funkwhale_api.common import channels
|
|
||||||
from funkwhale_api.activity import record
|
from funkwhale_api.activity import record
|
||||||
|
from funkwhale_api.common import channels
|
||||||
|
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
|
||||||
record.registry.register_serializer(
|
record.registry.register_serializer(serializers.ListeningActivitySerializer)
|
||||||
serializers.ListeningActivitySerializer)
|
|
||||||
|
|
||||||
|
|
||||||
@record.registry.register_consumer('history.Listening')
|
@record.registry.register_consumer("history.Listening")
|
||||||
def broadcast_listening_to_instance_activity(data, obj):
|
def broadcast_listening_to_instance_activity(data, obj):
|
||||||
if obj.user.privacy_level not in ['instance', 'everyone']:
|
if obj.user.privacy_level not in ["instance", "everyone"]:
|
||||||
return
|
return
|
||||||
|
|
||||||
channels.group_send('instance_activity', {
|
channels.group_send(
|
||||||
'type': 'event.send',
|
"instance_activity", {"type": "event.send", "text": "", "data": data}
|
||||||
'text': '',
|
)
|
||||||
'data': data
|
|
||||||
})
|
|
||||||
|
|
|
@ -2,11 +2,9 @@ from django.contrib import admin
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
@admin.register(models.Listening)
|
@admin.register(models.Listening)
|
||||||
class ListeningAdmin(admin.ModelAdmin):
|
class ListeningAdmin(admin.ModelAdmin):
|
||||||
list_display = ['track', 'creation_date', 'user', 'session_key']
|
list_display = ["track", "creation_date", "user", "session_key"]
|
||||||
search_fields = ['track__name', 'user__username']
|
search_fields = ["track__name", "user__username"]
|
||||||
list_select_related = [
|
list_select_related = ["user", "track"]
|
||||||
'user',
|
|
||||||
'track'
|
|
||||||
]
|
|
||||||
|
|
|
@ -11,4 +11,4 @@ class ListeningFactory(factory.django.DjangoModelFactory):
|
||||||
track = factory.SubFactory(factories.TrackFactory)
|
track = factory.SubFactory(factories.TrackFactory)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = 'history.Listening'
|
model = "history.Listening"
|
||||||
|
|
|
@ -9,22 +9,52 @@ import django.utils.timezone
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('music', '0008_auto_20160529_1456'),
|
("music", "0008_auto_20160529_1456"),
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Listening',
|
name="Listening",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)),
|
(
|
||||||
('end_date', models.DateTimeField(null=True, blank=True, default=django.utils.timezone.now)),
|
"id",
|
||||||
('session_key', models.CharField(null=True, blank=True, max_length=100)),
|
models.AutoField(
|
||||||
('track', models.ForeignKey(related_name='listenings', to='music.Track', on_delete=models.CASCADE)),
|
verbose_name="ID",
|
||||||
('user', models.ForeignKey(blank=True, null=True, related_name='listenings', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
|
primary_key=True,
|
||||||
],
|
serialize=False,
|
||||||
options={
|
auto_created=True,
|
||||||
'ordering': ('-end_date',),
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"end_date",
|
||||||
|
models.DateTimeField(
|
||||||
|
null=True, blank=True, default=django.utils.timezone.now
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"session_key",
|
||||||
|
models.CharField(null=True, blank=True, max_length=100),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"track",
|
||||||
|
models.ForeignKey(
|
||||||
|
related_name="listenings",
|
||||||
|
to="music.Track",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
related_name="listenings",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={"ordering": ("-end_date",)},
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
|
@ -5,18 +5,13 @@ from django.db import migrations
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [("history", "0001_initial")]
|
||||||
('history', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterModelOptions(
|
migrations.AlterModelOptions(
|
||||||
name='listening',
|
name="listening", options={"ordering": ("-creation_date",)}
|
||||||
options={'ordering': ('-creation_date',)},
|
|
||||||
),
|
),
|
||||||
migrations.RenameField(
|
migrations.RenameField(
|
||||||
model_name='listening',
|
model_name="listening", old_name="end_date", new_name="creation_date"
|
||||||
old_name='end_date',
|
|
||||||
new_name='creation_date',
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,26 +1,25 @@
|
||||||
from django.utils import timezone
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.core.exceptions import ValidationError
|
from django.utils import timezone
|
||||||
|
|
||||||
from funkwhale_api.music.models import Track
|
from funkwhale_api.music.models import Track
|
||||||
|
|
||||||
|
|
||||||
class Listening(models.Model):
|
class Listening(models.Model):
|
||||||
creation_date = models.DateTimeField(
|
creation_date = models.DateTimeField(default=timezone.now, null=True, blank=True)
|
||||||
default=timezone.now, null=True, blank=True)
|
|
||||||
track = models.ForeignKey(
|
track = models.ForeignKey(
|
||||||
Track, related_name="listenings", on_delete=models.CASCADE)
|
Track, related_name="listenings", on_delete=models.CASCADE
|
||||||
|
)
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
'users.User',
|
"users.User",
|
||||||
related_name="listenings",
|
related_name="listenings",
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
on_delete=models.CASCADE)
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
session_key = models.CharField(max_length=100, null=True, blank=True)
|
session_key = models.CharField(max_length=100, null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('-creation_date',)
|
ordering = ("-creation_date",)
|
||||||
|
|
||||||
def get_activity_url(self):
|
def get_activity_url(self):
|
||||||
return '{}/listenings/tracks/{}'.format(
|
return "{}/listenings/tracks/{}".format(self.user.get_activity_url(), self.pk)
|
||||||
self.user.get_activity_url(), self.pk)
|
|
||||||
|
|
|
@ -9,35 +9,27 @@ from . import models
|
||||||
|
|
||||||
class ListeningActivitySerializer(activity_serializers.ModelSerializer):
|
class ListeningActivitySerializer(activity_serializers.ModelSerializer):
|
||||||
type = serializers.SerializerMethodField()
|
type = serializers.SerializerMethodField()
|
||||||
object = TrackActivitySerializer(source='track')
|
object = TrackActivitySerializer(source="track")
|
||||||
actor = UserActivitySerializer(source='user')
|
actor = UserActivitySerializer(source="user")
|
||||||
published = serializers.DateTimeField(source='creation_date')
|
published = serializers.DateTimeField(source="creation_date")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Listening
|
model = models.Listening
|
||||||
fields = [
|
fields = ["id", "local_id", "object", "type", "actor", "published"]
|
||||||
'id',
|
|
||||||
'local_id',
|
|
||||||
'object',
|
|
||||||
'type',
|
|
||||||
'actor',
|
|
||||||
'published'
|
|
||||||
]
|
|
||||||
|
|
||||||
def get_actor(self, obj):
|
def get_actor(self, obj):
|
||||||
return UserActivitySerializer(obj.user).data
|
return UserActivitySerializer(obj.user).data
|
||||||
|
|
||||||
def get_type(self, obj):
|
def get_type(self, obj):
|
||||||
return 'Listen'
|
return "Listen"
|
||||||
|
|
||||||
|
|
||||||
class ListeningSerializer(serializers.ModelSerializer):
|
class ListeningSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Listening
|
model = models.Listening
|
||||||
fields = ('id', 'user', 'track', 'creation_date')
|
fields = ("id", "user", "track", "creation_date")
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
validated_data['user'] = self.context['user']
|
validated_data["user"] = self.context["user"]
|
||||||
|
|
||||||
return super().create(validated_data)
|
return super().create(validated_data)
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
from django.conf.urls import include, url
|
from rest_framework import routers
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
from rest_framework import routers
|
|
||||||
router = routers.SimpleRouter()
|
router = routers.SimpleRouter()
|
||||||
router.register(r'listenings', views.ListeningViewSet, 'listenings')
|
router.register(r"listenings", views.ListeningViewSet, "listenings")
|
||||||
|
|
||||||
urlpatterns = router.urls
|
urlpatterns = router.urls
|
||||||
|
|
|
@ -1,20 +1,13 @@
|
||||||
from rest_framework import generics, mixins, viewsets
|
from rest_framework import mixins, permissions, viewsets
|
||||||
from rest_framework import permissions
|
|
||||||
from rest_framework import status
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework.decorators import detail_route
|
|
||||||
|
|
||||||
from funkwhale_api.activity import record
|
from funkwhale_api.activity import record
|
||||||
from funkwhale_api.common.permissions import ConditionalAuthentication
|
|
||||||
|
|
||||||
from . import models
|
from . import models, serializers
|
||||||
from . import serializers
|
|
||||||
|
|
||||||
|
|
||||||
class ListeningViewSet(
|
class ListeningViewSet(
|
||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
|
||||||
mixins.RetrieveModelMixin,
|
):
|
||||||
viewsets.GenericViewSet):
|
|
||||||
|
|
||||||
serializer_class = serializers.ListeningSerializer
|
serializer_class = serializers.ListeningSerializer
|
||||||
queryset = models.Listening.objects.all()
|
queryset = models.Listening.objects.all()
|
||||||
|
@ -31,5 +24,5 @@ class ListeningViewSet(
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
context = super().get_serializer_context()
|
context = super().get_serializer_context()
|
||||||
context['user'] = self.request.user
|
context["user"] = self.request.user
|
||||||
return context
|
return context
|
||||||
|
|
|
@ -5,4 +5,4 @@ class InstanceActivityConsumer(JsonAuthConsumer):
|
||||||
groups = ["instance_activity"]
|
groups = ["instance_activity"]
|
||||||
|
|
||||||
def event_send(self, message):
|
def event_send(self, message):
|
||||||
self.send_json(message['data'])
|
self.send_json(message["data"])
|
||||||
|
|
|
@ -1,93 +1,84 @@
|
||||||
from django.forms import widgets
|
from django.forms import widgets
|
||||||
|
|
||||||
from dynamic_preferences import types
|
from dynamic_preferences import types
|
||||||
from dynamic_preferences.registries import global_preferences_registry
|
from dynamic_preferences.registries import global_preferences_registry
|
||||||
|
|
||||||
raven = types.Section('raven')
|
raven = types.Section("raven")
|
||||||
instance = types.Section('instance')
|
instance = types.Section("instance")
|
||||||
|
|
||||||
|
|
||||||
@global_preferences_registry.register
|
@global_preferences_registry.register
|
||||||
class InstanceName(types.StringPreference):
|
class InstanceName(types.StringPreference):
|
||||||
show_in_api = True
|
show_in_api = True
|
||||||
section = instance
|
section = instance
|
||||||
name = 'name'
|
name = "name"
|
||||||
default = ''
|
default = ""
|
||||||
verbose_name = 'Public name'
|
verbose_name = "Public name"
|
||||||
help_text = 'The public name of your instance, displayed in the about page.'
|
help_text = "The public name of your instance, displayed in the about page."
|
||||||
field_kwargs = {
|
field_kwargs = {"required": False}
|
||||||
'required': False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@global_preferences_registry.register
|
@global_preferences_registry.register
|
||||||
class InstanceShortDescription(types.StringPreference):
|
class InstanceShortDescription(types.StringPreference):
|
||||||
show_in_api = True
|
show_in_api = True
|
||||||
section = instance
|
section = instance
|
||||||
name = 'short_description'
|
name = "short_description"
|
||||||
default = ''
|
default = ""
|
||||||
verbose_name = 'Short description'
|
verbose_name = "Short description"
|
||||||
help_text = 'Instance succinct description, displayed in the about page.'
|
help_text = "Instance succinct description, displayed in the about page."
|
||||||
field_kwargs = {
|
field_kwargs = {"required": False}
|
||||||
'required': False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@global_preferences_registry.register
|
@global_preferences_registry.register
|
||||||
class InstanceLongDescription(types.StringPreference):
|
class InstanceLongDescription(types.StringPreference):
|
||||||
show_in_api = True
|
show_in_api = True
|
||||||
section = instance
|
section = instance
|
||||||
name = 'long_description'
|
name = "long_description"
|
||||||
verbose_name = 'Long description'
|
verbose_name = "Long description"
|
||||||
default = ''
|
default = ""
|
||||||
help_text = 'Instance long description, displayed in the about page (markdown allowed).'
|
help_text = (
|
||||||
|
"Instance long description, displayed in the about page (markdown allowed)."
|
||||||
|
)
|
||||||
widget = widgets.Textarea
|
widget = widgets.Textarea
|
||||||
field_kwargs = {
|
field_kwargs = {"required": False}
|
||||||
'required': False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@global_preferences_registry.register
|
@global_preferences_registry.register
|
||||||
class RavenDSN(types.StringPreference):
|
class RavenDSN(types.StringPreference):
|
||||||
show_in_api = True
|
show_in_api = True
|
||||||
section = raven
|
section = raven
|
||||||
name = 'front_dsn'
|
name = "front_dsn"
|
||||||
default = 'https://9e0562d46b09442bb8f6844e50cbca2b@sentry.eliotberriot.com/4'
|
default = "https://9e0562d46b09442bb8f6844e50cbca2b@sentry.eliotberriot.com/4"
|
||||||
verbose_name = 'Raven DSN key (front-end)'
|
verbose_name = "Raven DSN key (front-end)"
|
||||||
|
|
||||||
help_text = (
|
help_text = (
|
||||||
'A Raven DSN key used to report front-ent errors to '
|
"A Raven DSN key used to report front-ent errors to "
|
||||||
'a sentry instance. Keeping the default one will report errors to '
|
"a sentry instance. Keeping the default one will report errors to "
|
||||||
'Funkwhale developers.'
|
"Funkwhale developers."
|
||||||
)
|
)
|
||||||
field_kwargs = {
|
field_kwargs = {"required": False}
|
||||||
'required': False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@global_preferences_registry.register
|
@global_preferences_registry.register
|
||||||
class RavenEnabled(types.BooleanPreference):
|
class RavenEnabled(types.BooleanPreference):
|
||||||
show_in_api = True
|
show_in_api = True
|
||||||
section = raven
|
section = raven
|
||||||
name = 'front_enabled'
|
name = "front_enabled"
|
||||||
default = False
|
default = False
|
||||||
verbose_name = (
|
verbose_name = "Report front-end errors with Raven"
|
||||||
'Report front-end errors with Raven'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@global_preferences_registry.register
|
@global_preferences_registry.register
|
||||||
class InstanceNodeinfoEnabled(types.BooleanPreference):
|
class InstanceNodeinfoEnabled(types.BooleanPreference):
|
||||||
show_in_api = False
|
show_in_api = False
|
||||||
section = instance
|
section = instance
|
||||||
name = 'nodeinfo_enabled'
|
name = "nodeinfo_enabled"
|
||||||
default = True
|
default = True
|
||||||
verbose_name = 'Enable nodeinfo endpoint'
|
verbose_name = "Enable nodeinfo endpoint"
|
||||||
help_text = (
|
help_text = (
|
||||||
'This endpoint is needed for your about page to work. '
|
"This endpoint is needed for your about page to work. "
|
||||||
'It\'s also helpful for the various monitoring '
|
"It's also helpful for the various monitoring "
|
||||||
'tools that map and analyzize the fediverse, '
|
"tools that map and analyzize the fediverse, "
|
||||||
'but you can disable it completely if needed.'
|
"but you can disable it completely if needed."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -95,13 +86,13 @@ class InstanceNodeinfoEnabled(types.BooleanPreference):
|
||||||
class InstanceNodeinfoPrivate(types.BooleanPreference):
|
class InstanceNodeinfoPrivate(types.BooleanPreference):
|
||||||
show_in_api = False
|
show_in_api = False
|
||||||
section = instance
|
section = instance
|
||||||
name = 'nodeinfo_private'
|
name = "nodeinfo_private"
|
||||||
default = False
|
default = False
|
||||||
verbose_name = 'Private mode in nodeinfo'
|
verbose_name = "Private mode in nodeinfo"
|
||||||
help_text = (
|
help_text = (
|
||||||
'Indicate in the nodeinfo endpoint that you do not want your instance '
|
"Indicate in the nodeinfo endpoint that you do not want your instance "
|
||||||
'to be tracked by third-party services. '
|
"to be tracked by third-party services. "
|
||||||
'There is no guarantee these tools will honor this setting though.'
|
"There is no guarantee these tools will honor this setting though."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -109,10 +100,10 @@ class InstanceNodeinfoPrivate(types.BooleanPreference):
|
||||||
class InstanceNodeinfoStatsEnabled(types.BooleanPreference):
|
class InstanceNodeinfoStatsEnabled(types.BooleanPreference):
|
||||||
show_in_api = False
|
show_in_api = False
|
||||||
section = instance
|
section = instance
|
||||||
name = 'nodeinfo_stats_enabled'
|
name = "nodeinfo_stats_enabled"
|
||||||
default = True
|
default = True
|
||||||
verbose_name = 'Enable usage and library stats in nodeinfo endpoint'
|
verbose_name = "Enable usage and library stats in nodeinfo endpoint"
|
||||||
help_text = (
|
help_text = (
|
||||||
'Disable this if you don\'t want to share usage and library statistics '
|
"Disable this if you don't want to share usage and library statistics "
|
||||||
'in the nodeinfo endpoint but don\'t want to disable it completely.'
|
"in the nodeinfo endpoint but don't want to disable it completely."
|
||||||
)
|
)
|
||||||
|
|
|
@ -5,71 +5,46 @@ from funkwhale_api.common import preferences
|
||||||
|
|
||||||
from . import stats
|
from . import stats
|
||||||
|
|
||||||
|
store = memoize.djangocache.Cache("default")
|
||||||
store = memoize.djangocache.Cache('default')
|
memo = memoize.Memoizer(store, namespace="instance:stats")
|
||||||
memo = memoize.Memoizer(store, namespace='instance:stats')
|
|
||||||
|
|
||||||
|
|
||||||
def get():
|
def get():
|
||||||
share_stats = preferences.get('instance__nodeinfo_stats_enabled')
|
share_stats = preferences.get("instance__nodeinfo_stats_enabled")
|
||||||
private = preferences.get('instance__nodeinfo_private')
|
|
||||||
data = {
|
data = {
|
||||||
'version': '2.0',
|
"version": "2.0",
|
||||||
'software': {
|
"software": {"name": "funkwhale", "version": funkwhale_api.__version__},
|
||||||
'name': 'funkwhale',
|
"protocols": ["activitypub"],
|
||||||
'version': funkwhale_api.__version__
|
"services": {"inbound": [], "outbound": []},
|
||||||
|
"openRegistrations": preferences.get("users__registration_enabled"),
|
||||||
|
"usage": {"users": {"total": 0}},
|
||||||
|
"metadata": {
|
||||||
|
"private": preferences.get("instance__nodeinfo_private"),
|
||||||
|
"shortDescription": preferences.get("instance__short_description"),
|
||||||
|
"longDescription": preferences.get("instance__long_description"),
|
||||||
|
"nodeName": preferences.get("instance__name"),
|
||||||
|
"library": {
|
||||||
|
"federationEnabled": preferences.get("federation__enabled"),
|
||||||
|
"federationNeedsApproval": preferences.get(
|
||||||
|
"federation__music_needs_approval"
|
||||||
|
),
|
||||||
|
"anonymousCanListen": preferences.get(
|
||||||
|
"common__api_authentication_required"
|
||||||
|
),
|
||||||
},
|
},
|
||||||
'protocols': ['activitypub'],
|
|
||||||
'services': {
|
|
||||||
'inbound': [],
|
|
||||||
'outbound': []
|
|
||||||
},
|
},
|
||||||
'openRegistrations': preferences.get('users__registration_enabled'),
|
|
||||||
'usage': {
|
|
||||||
'users': {
|
|
||||||
'total': 0,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'metadata': {
|
|
||||||
'private': preferences.get('instance__nodeinfo_private'),
|
|
||||||
'shortDescription': preferences.get('instance__short_description'),
|
|
||||||
'longDescription': preferences.get('instance__long_description'),
|
|
||||||
'nodeName': preferences.get('instance__name'),
|
|
||||||
'library': {
|
|
||||||
'federationEnabled': preferences.get('federation__enabled'),
|
|
||||||
'federationNeedsApproval': preferences.get('federation__music_needs_approval'),
|
|
||||||
'anonymousCanListen': preferences.get('common__api_authentication_required'),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if share_stats:
|
if share_stats:
|
||||||
getter = memo(
|
getter = memo(lambda: stats.get(), max_age=600)
|
||||||
lambda: stats.get(),
|
|
||||||
max_age=600
|
|
||||||
)
|
|
||||||
statistics = getter()
|
statistics = getter()
|
||||||
data['usage']['users']['total'] = statistics['users']
|
data["usage"]["users"]["total"] = statistics["users"]
|
||||||
data['metadata']['library']['tracks'] = {
|
data["metadata"]["library"]["tracks"] = {"total": statistics["tracks"]}
|
||||||
'total': statistics['tracks'],
|
data["metadata"]["library"]["artists"] = {"total": statistics["artists"]}
|
||||||
}
|
data["metadata"]["library"]["albums"] = {"total": statistics["albums"]}
|
||||||
data['metadata']['library']['artists'] = {
|
data["metadata"]["library"]["music"] = {"hours": statistics["music_duration"]}
|
||||||
'total': statistics['artists'],
|
|
||||||
}
|
|
||||||
data['metadata']['library']['albums'] = {
|
|
||||||
'total': statistics['albums'],
|
|
||||||
}
|
|
||||||
data['metadata']['library']['music'] = {
|
|
||||||
'hours': statistics['music_duration']
|
|
||||||
}
|
|
||||||
|
|
||||||
data['metadata']['usage'] = {
|
data["metadata"]["usage"] = {
|
||||||
'favorites': {
|
"favorites": {"tracks": {"total": statistics["track_favorites"]}},
|
||||||
'tracks': {
|
"listenings": {"total": statistics["listenings"]},
|
||||||
'total': statistics['track_favorites'],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'listenings': {
|
|
||||||
'total': statistics['listenings']
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
|
|
|
@ -8,13 +8,13 @@ from funkwhale_api.users.models import User
|
||||||
|
|
||||||
def get():
|
def get():
|
||||||
return {
|
return {
|
||||||
'users': get_users(),
|
"users": get_users(),
|
||||||
'tracks': get_tracks(),
|
"tracks": get_tracks(),
|
||||||
'albums': get_albums(),
|
"albums": get_albums(),
|
||||||
'artists': get_artists(),
|
"artists": get_artists(),
|
||||||
'track_favorites': get_track_favorites(),
|
"track_favorites": get_track_favorites(),
|
||||||
'listenings': get_listenings(),
|
"listenings": get_listenings(),
|
||||||
'music_duration': get_music_duration(),
|
"music_duration": get_music_duration(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -43,9 +43,7 @@ def get_artists():
|
||||||
|
|
||||||
|
|
||||||
def get_music_duration():
|
def get_music_duration():
|
||||||
seconds = models.TrackFile.objects.aggregate(
|
seconds = models.TrackFile.objects.aggregate(d=Sum("duration"))["d"]
|
||||||
d=Sum('duration'),
|
|
||||||
)['d']
|
|
||||||
if seconds:
|
if seconds:
|
||||||
return seconds / 3600
|
return seconds / 3600
|
||||||
return 0
|
return 0
|
||||||
|
|
|
@ -2,10 +2,11 @@ from django.conf.urls import url
|
||||||
from rest_framework import routers
|
from rest_framework import routers
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
admin_router = routers.SimpleRouter()
|
admin_router = routers.SimpleRouter()
|
||||||
admin_router.register(r'admin/settings', views.AdminSettings, 'admin-settings')
|
admin_router.register(r"admin/settings", views.AdminSettings, "admin-settings")
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r'^nodeinfo/2.0/$', views.NodeInfo.as_view(), name='nodeinfo-2.0'),
|
url(r"^nodeinfo/2.0/$", views.NodeInfo.as_view(), name="nodeinfo-2.0"),
|
||||||
url(r'^settings/$', views.InstanceSettings.as_view(), name='settings'),
|
url(r"^settings/$", views.InstanceSettings.as_view(), name="settings"),
|
||||||
] + admin_router.urls
|
] + admin_router.urls
|
||||||
|
|
|
@ -1,26 +1,22 @@
|
||||||
from rest_framework import views
|
|
||||||
from rest_framework.response import Response
|
|
||||||
|
|
||||||
from dynamic_preferences.api import serializers
|
from dynamic_preferences.api import serializers
|
||||||
from dynamic_preferences.api import viewsets as preferences_viewsets
|
from dynamic_preferences.api import viewsets as preferences_viewsets
|
||||||
from dynamic_preferences.registries import global_preferences_registry
|
from dynamic_preferences.registries import global_preferences_registry
|
||||||
|
from rest_framework import views
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from funkwhale_api.common import preferences
|
from funkwhale_api.common import preferences
|
||||||
from funkwhale_api.users.permissions import HasUserPermission
|
from funkwhale_api.users.permissions import HasUserPermission
|
||||||
|
|
||||||
from . import nodeinfo
|
from . import nodeinfo
|
||||||
from . import stats
|
|
||||||
|
|
||||||
|
NODEINFO_2_CONTENT_TYPE = "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8" # noqa
|
||||||
NODEINFO_2_CONTENT_TYPE = (
|
|
||||||
'application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8' # noqa
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AdminSettings(preferences_viewsets.GlobalPreferencesViewSet):
|
class AdminSettings(preferences_viewsets.GlobalPreferencesViewSet):
|
||||||
pagination_class = None
|
pagination_class = None
|
||||||
permission_classes = (HasUserPermission,)
|
permission_classes = (HasUserPermission,)
|
||||||
required_permissions = ['settings']
|
required_permissions = ["settings"]
|
||||||
|
|
||||||
|
|
||||||
class InstanceSettings(views.APIView):
|
class InstanceSettings(views.APIView):
|
||||||
permission_classes = []
|
permission_classes = []
|
||||||
|
@ -29,16 +25,11 @@ class InstanceSettings(views.APIView):
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
manager = global_preferences_registry.manager()
|
manager = global_preferences_registry.manager()
|
||||||
manager.all()
|
manager.all()
|
||||||
all_preferences = manager.model.objects.all().order_by(
|
all_preferences = manager.model.objects.all().order_by("section", "name")
|
||||||
'section', 'name'
|
|
||||||
)
|
|
||||||
api_preferences = [
|
api_preferences = [
|
||||||
p
|
p for p in all_preferences if getattr(p.preference, "show_in_api", False)
|
||||||
for p in all_preferences
|
|
||||||
if getattr(p.preference, 'show_in_api', False)
|
|
||||||
]
|
]
|
||||||
data = serializers.GlobalPreferenceSerializer(
|
data = serializers.GlobalPreferenceSerializer(api_preferences, many=True).data
|
||||||
api_preferences, many=True).data
|
|
||||||
return Response(data, status=200)
|
return Response(data, status=200)
|
||||||
|
|
||||||
|
|
||||||
|
@ -47,8 +38,7 @@ class NodeInfo(views.APIView):
|
||||||
authentication_classes = []
|
authentication_classes = []
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
if not preferences.get('instance__nodeinfo_enabled'):
|
if not preferences.get("instance__nodeinfo_enabled"):
|
||||||
return Response(status=404)
|
return Response(status=404)
|
||||||
data = nodeinfo.get()
|
data = nodeinfo.get()
|
||||||
return Response(
|
return Response(data, status=200, content_type=NODEINFO_2_CONTENT_TYPE)
|
||||||
data, status=200, content_type=NODEINFO_2_CONTENT_TYPE)
|
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
from django.db.models import Count
|
|
||||||
|
|
||||||
from django_filters import rest_framework as filters
|
from django_filters import rest_framework as filters
|
||||||
|
|
||||||
|
@ -7,19 +6,15 @@ from funkwhale_api.music import models as music_models
|
||||||
|
|
||||||
|
|
||||||
class ManageTrackFileFilterSet(filters.FilterSet):
|
class ManageTrackFileFilterSet(filters.FilterSet):
|
||||||
q = fields.SearchFilter(search_fields=[
|
q = fields.SearchFilter(
|
||||||
'track__title',
|
search_fields=[
|
||||||
'track__album__title',
|
"track__title",
|
||||||
'track__artist__name',
|
"track__album__title",
|
||||||
'source',
|
"track__artist__name",
|
||||||
])
|
"source",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = music_models.TrackFile
|
model = music_models.TrackFile
|
||||||
fields = [
|
fields = ["q", "track__album", "track__artist", "track", "library_track"]
|
||||||
'q',
|
|
||||||
'track__album',
|
|
||||||
'track__artist',
|
|
||||||
'track',
|
|
||||||
'library_track'
|
|
||||||
]
|
|
||||||
|
|
|
@ -10,12 +10,7 @@ from . import filters
|
||||||
class ManageTrackFileArtistSerializer(serializers.ModelSerializer):
|
class ManageTrackFileArtistSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = music_models.Artist
|
model = music_models.Artist
|
||||||
fields = [
|
fields = ["id", "mbid", "creation_date", "name"]
|
||||||
'id',
|
|
||||||
'mbid',
|
|
||||||
'creation_date',
|
|
||||||
'name',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class ManageTrackFileAlbumSerializer(serializers.ModelSerializer):
|
class ManageTrackFileAlbumSerializer(serializers.ModelSerializer):
|
||||||
|
@ -24,13 +19,13 @@ class ManageTrackFileAlbumSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = music_models.Album
|
model = music_models.Album
|
||||||
fields = (
|
fields = (
|
||||||
'id',
|
"id",
|
||||||
'mbid',
|
"mbid",
|
||||||
'title',
|
"title",
|
||||||
'artist',
|
"artist",
|
||||||
'release_date',
|
"release_date",
|
||||||
'cover',
|
"cover",
|
||||||
'creation_date',
|
"creation_date",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -40,15 +35,7 @@ class ManageTrackFileTrackSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = music_models.Track
|
model = music_models.Track
|
||||||
fields = (
|
fields = ("id", "mbid", "title", "album", "artist", "creation_date", "position")
|
||||||
'id',
|
|
||||||
'mbid',
|
|
||||||
'title',
|
|
||||||
'album',
|
|
||||||
'artist',
|
|
||||||
'creation_date',
|
|
||||||
'position',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ManageTrackFileSerializer(serializers.ModelSerializer):
|
class ManageTrackFileSerializer(serializers.ModelSerializer):
|
||||||
|
@ -57,24 +44,24 @@ class ManageTrackFileSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = music_models.TrackFile
|
model = music_models.TrackFile
|
||||||
fields = (
|
fields = (
|
||||||
'id',
|
"id",
|
||||||
'path',
|
"path",
|
||||||
'source',
|
"source",
|
||||||
'filename',
|
"filename",
|
||||||
'mimetype',
|
"mimetype",
|
||||||
'track',
|
"track",
|
||||||
'duration',
|
"duration",
|
||||||
'mimetype',
|
"mimetype",
|
||||||
'bitrate',
|
"bitrate",
|
||||||
'size',
|
"size",
|
||||||
'path',
|
"path",
|
||||||
'library_track',
|
"library_track",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ManageTrackFileActionSerializer(common_serializers.ActionSerializer):
|
class ManageTrackFileActionSerializer(common_serializers.ActionSerializer):
|
||||||
actions = ['delete']
|
actions = ["delete"]
|
||||||
dangerous_actions = ['delete']
|
dangerous_actions = ["delete"]
|
||||||
filterset_class = filters.ManageTrackFileFilterSet
|
filterset_class = filters.ManageTrackFileFilterSet
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
from django.conf.urls import include, url
|
from django.conf.urls import include, url
|
||||||
|
from rest_framework import routers
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
from rest_framework import routers
|
|
||||||
library_router = routers.SimpleRouter()
|
library_router = routers.SimpleRouter()
|
||||||
library_router.register(r'track-files', views.ManageTrackFileViewSet, 'track-files')
|
library_router.register(r"track-files", views.ManageTrackFileViewSet, "track-files")
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r'^library/',
|
url(r"^library/", include((library_router.urls, "instance"), namespace="library"))
|
||||||
include((library_router.urls, 'instance'), namespace='library')),
|
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,48 +1,42 @@
|
||||||
from rest_framework import mixins
|
from rest_framework import mixins, response, viewsets
|
||||||
from rest_framework import response
|
|
||||||
from rest_framework import viewsets
|
|
||||||
from rest_framework.decorators import list_route
|
from rest_framework.decorators import list_route
|
||||||
|
|
||||||
from funkwhale_api.music import models as music_models
|
from funkwhale_api.music import models as music_models
|
||||||
from funkwhale_api.users.permissions import HasUserPermission
|
from funkwhale_api.users.permissions import HasUserPermission
|
||||||
|
|
||||||
from . import filters
|
from . import filters, serializers
|
||||||
from . import serializers
|
|
||||||
|
|
||||||
|
|
||||||
class ManageTrackFileViewSet(
|
class ManageTrackFileViewSet(
|
||||||
mixins.ListModelMixin,
|
mixins.ListModelMixin,
|
||||||
mixins.RetrieveModelMixin,
|
mixins.RetrieveModelMixin,
|
||||||
mixins.DestroyModelMixin,
|
mixins.DestroyModelMixin,
|
||||||
viewsets.GenericViewSet):
|
viewsets.GenericViewSet,
|
||||||
|
):
|
||||||
queryset = (
|
queryset = (
|
||||||
music_models.TrackFile.objects.all()
|
music_models.TrackFile.objects.all()
|
||||||
.select_related(
|
.select_related("track__artist", "track__album__artist", "library_track")
|
||||||
'track__artist',
|
.order_by("-id")
|
||||||
'track__album__artist',
|
|
||||||
'library_track')
|
|
||||||
.order_by('-id')
|
|
||||||
)
|
)
|
||||||
serializer_class = serializers.ManageTrackFileSerializer
|
serializer_class = serializers.ManageTrackFileSerializer
|
||||||
filter_class = filters.ManageTrackFileFilterSet
|
filter_class = filters.ManageTrackFileFilterSet
|
||||||
permission_classes = (HasUserPermission,)
|
permission_classes = (HasUserPermission,)
|
||||||
required_permissions = ['library']
|
required_permissions = ["library"]
|
||||||
ordering_fields = [
|
ordering_fields = [
|
||||||
'accessed_date',
|
"accessed_date",
|
||||||
'modification_date',
|
"modification_date",
|
||||||
'creation_date',
|
"creation_date",
|
||||||
'track__artist__name',
|
"track__artist__name",
|
||||||
'bitrate',
|
"bitrate",
|
||||||
'size',
|
"size",
|
||||||
'duration',
|
"duration",
|
||||||
]
|
]
|
||||||
|
|
||||||
@list_route(methods=['post'])
|
@list_route(methods=["post"])
|
||||||
def action(self, request, *args, **kwargs):
|
def action(self, request, *args, **kwargs):
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
serializer = serializers.ManageTrackFileActionSerializer(
|
serializer = serializers.ManageTrackFileActionSerializer(
|
||||||
request.data,
|
request.data, queryset=queryset
|
||||||
queryset=queryset,
|
|
||||||
)
|
)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
result = serializer.save()
|
result = serializer.save()
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue