├── MANIFEST.in ├── src └── cloudplayer │ ├── api │ ├── tests │ │ ├── http │ │ │ ├── __init__.py │ │ │ ├── test_favourite.py │ │ │ ├── test_session.py │ │ │ ├── test_account.py │ │ │ ├── test_playlist.py │ │ │ ├── test_track_comment.py │ │ │ ├── test_base.py │ │ │ └── test_auth.py │ │ ├── model │ │ │ ├── __init__.py │ │ │ ├── test_base.py │ │ │ ├── test_user.py │ │ │ ├── test_image.py │ │ │ ├── test_token.py │ │ │ ├── test_account.py │ │ │ ├── test_favourite.py │ │ │ ├── test_playlist.py │ │ │ ├── test_provider.py │ │ │ ├── test_favourite_item.py │ │ │ └── test_playlist_item.py │ │ ├── ws │ │ │ ├── __init__.py │ │ │ ├── test_user.py │ │ │ ├── test_base.py │ │ │ └── test_account.py │ │ ├── access │ │ │ ├── __init__.py │ │ │ ├── test_action.py │ │ │ ├── test_fields.py │ │ │ ├── test_rule.py │ │ │ ├── test_principal.py │ │ │ └── test_policy.py │ │ ├── controller │ │ │ ├── __init__.py │ │ │ ├── test_account.py │ │ │ ├── test_favourite.py │ │ │ ├── test_track.py │ │ │ ├── test_favourite_item.py │ │ │ ├── test_playlist_item.py │ │ │ ├── test_user.py │ │ │ ├── test_playlist.py │ │ │ └── test_token.py │ │ ├── __init__.py │ │ ├── upstream │ │ │ └── youtube │ │ │ │ ├── search.json │ │ │ │ └── videos.json │ │ ├── test_util.py │ │ ├── test_routing.py │ │ ├── test_app.py │ │ ├── expected │ │ │ └── tracks │ │ │ │ ├── youtube.json │ │ │ │ └── soundcloud.json │ │ └── test_handler.py │ ├── __init__.py │ ├── ws │ │ ├── __init__.py │ │ ├── user.py │ │ ├── account.py │ │ ├── playlist.py │ │ └── base.py │ ├── http │ │ ├── __init__.py │ │ ├── user.py │ │ ├── account.py │ │ ├── favourite.py │ │ ├── session.py │ │ ├── track_comment.py │ │ ├── provider.py │ │ ├── token.py │ │ ├── playlist.py │ │ ├── playlist_item.py │ │ ├── favourite_item.py │ │ ├── track.py │ │ ├── proxy.py │ │ ├── socket.py │ │ ├── auth.py │ │ └── base.py │ ├── model │ │ ├── __init__.py │ │ ├── tracklist_item.py │ │ ├── favourite.py │ │ ├── provider.py │ │ ├── track_comment.py │ │ ├── user.py │ │ ├── tracklist.py │ │ ├── token.py │ │ ├── session.py │ │ ├── image.py │ │ ├── favourite_item.py │ │ ├── playlist_item.py │ │ ├── playlist.py │ │ ├── track.py │ │ ├── account.py │ │ └── base.py │ ├── controller │ │ ├── __init__.py │ │ ├── session.py │ │ ├── provider.py │ │ ├── account.py │ │ ├── favourite.py │ │ ├── user.py │ │ ├── playlist.py │ │ ├── favourite_item.py │ │ ├── playlist_item.py │ │ ├── token.py │ │ ├── track_comment.py │ │ └── track.py │ ├── access │ │ ├── __init__.py │ │ ├── action.py │ │ ├── fields.py │ │ ├── rule.py │ │ ├── principal.py │ │ └── policy.py │ ├── routing.py │ ├── util.py │ └── handler.py │ └── __init__.py ├── static ├── robots.txt ├── favicon.ico └── close.html ├── scripts ├── stop.sh ├── validate.sh ├── run.sh ├── init-database.sh ├── install-as-user.sh ├── init-database.sql └── install-as-root.sh ├── source ├── fonts │ ├── slate.eot │ ├── slate.ttf │ ├── slate.woff │ ├── slate.woff2 │ └── slate.svg ├── images │ └── logo.png ├── index.rst └── conf.py ├── pytest.ini ├── readthedocs.yml ├── .gitignore ├── api.conf ├── spec ├── provider.yml ├── session.yml ├── queue.yml ├── token.yml ├── user.yml ├── history.yml ├── favourite.yml ├── image.yml ├── track_comment.yml ├── playback.yml ├── playlist_item.yml ├── favourite_item.yml ├── track.yml ├── account.yml ├── playlist.yml └── asyncapi.yml ├── widdershins.json ├── .gitlab-ci.yml ├── appspec.yml ├── Makefile ├── aws.py ├── README.md ├── dev.py ├── setup.py ├── cloudplayer.conf └── .travis.yml /MANIFEST.in: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/http/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/model/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/ws/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/access/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/controller/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/model/test_base.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # python package 2 | -------------------------------------------------------------------------------- /scripts/stop.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ex 3 | 4 | supervisorctl stop api 5 | -------------------------------------------------------------------------------- /scripts/validate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ex 3 | 4 | supervisorctl status api 5 | -------------------------------------------------------------------------------- /src/cloudplayer/__init__.py: -------------------------------------------------------------------------------- 1 | __import__('pkg_resources').declare_namespace(__name__) 2 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cloud-Player/api/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /source/fonts/slate.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cloud-Player/api/HEAD/source/fonts/slate.eot -------------------------------------------------------------------------------- /source/fonts/slate.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cloud-Player/api/HEAD/source/fonts/slate.ttf -------------------------------------------------------------------------------- /source/fonts/slate.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cloud-Player/api/HEAD/source/fonts/slate.woff -------------------------------------------------------------------------------- /source/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cloud-Player/api/HEAD/source/images/logo.png -------------------------------------------------------------------------------- /scripts/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ex 3 | 4 | supervisorctl start api 5 | service nginx reload 6 | -------------------------------------------------------------------------------- /source/fonts/slate.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cloud-Player/api/HEAD/source/fonts/slate.woff2 -------------------------------------------------------------------------------- /scripts/init-database.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ex 3 | 4 | psql -f /srv/cloudplayer/scripts/init-database.sql 5 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --pep8 --showlocals --tb=native --cov=cloudplayer.api src/cloudplayer/api/tests 3 | timeout = 60 4 | postgresql_port = 8852 5 | redis_port = 8869 6 | -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | formats: 2 | - none 3 | build: 4 | image: latest 5 | python: 6 | version: 3.6 7 | pip_install: true 8 | extra_requirements: 9 | - test 10 | - doc 11 | -------------------------------------------------------------------------------- /scripts/install-as-user.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ex 3 | 4 | cd /srv/cloudplayer 5 | python3.6 -m venv --upgrade --copies . 6 | bin/pip3.6 install --upgrade pip 7 | bin/pip3.6 install -e . 8 | -------------------------------------------------------------------------------- /src/cloudplayer/api/__init__.py: -------------------------------------------------------------------------------- 1 | from tornado.web import HTTPError as APIException 2 | 3 | __all__ = [ 4 | 'APIException' 5 | ] 6 | __import__('pkg_resources').declare_namespace(__name__) 7 | -------------------------------------------------------------------------------- /src/cloudplayer/api/ws/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import WSRequest, WSHandler 2 | 3 | __all__ = [ 4 | 'WSRequest', 5 | 'WSHandler' 6 | ] 7 | __import__('pkg_resources').declare_namespace(__name__) 8 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import HTTPException, HTTPHandler 2 | 3 | __all__ = [ 4 | 'HTTPException', 5 | 'HTTPHandler' 6 | ] 7 | __import__('pkg_resources').declare_namespace(__name__) 8 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Base, Encoder, Transient 2 | 3 | __all__ = [ 4 | 'Base', 5 | 'Encoder', 6 | 'Transient' 7 | ] 8 | __import__('pkg_resources').declare_namespace(__name__) 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .coverage 3 | .eggs 4 | .pytest_cache 5 | *.html 6 | *.html.md 7 | *.pyc 8 | *.pyo 9 | aws_upload 10 | bin 11 | build 12 | dist 13 | include 14 | lib 15 | share 16 | pip-selfcheck.json 17 | private.py 18 | pyvenv.cfg 19 | -------------------------------------------------------------------------------- /api.conf: -------------------------------------------------------------------------------- 1 | [program:api] 2 | command=/srv/cloudplayer/bin/api --config=/srv/cloudplayer/aws.py 3 | user=cloudplayer 4 | numprocs=1 5 | autostart=true 6 | autorestart=true 7 | startsecs=10 8 | startretries=3 9 | stopsignal=TERM 10 | stopwaitsecs=10 11 | -------------------------------------------------------------------------------- /spec/provider.yml: -------------------------------------------------------------------------------- 1 | Provider: 2 | type: object 3 | properties: 4 | id: 5 | description: Provider id 6 | type: string 7 | client_id: 8 | description: Provider client id 9 | type: string 10 | required: 11 | - id 12 | -------------------------------------------------------------------------------- /widdershins.json: -------------------------------------------------------------------------------- 1 | { 2 | "language_tabs": [ 3 | { 4 | "http": "HTTP" 5 | }, 6 | { 7 | "javascript": "JavaScript" 8 | }, 9 | { 10 | "python": "Python" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/cloudplayer/api/controller/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Controller, ControllerException, ProviderRegistry 2 | 3 | __all__ = [ 4 | 'Controller', 5 | 'ControllerException', 6 | 'ProviderRegistry' 7 | ] 8 | __import__('pkg_resources').declare_namespace(__name__) 9 | -------------------------------------------------------------------------------- /source/index.rst: -------------------------------------------------------------------------------- 1 | .. Cloud-Player API documentation master file 2 | 3 | Cloud-Player API 4 | ================ 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | :caption: Contents: 9 | 10 | 11 | Indices and tables 12 | ================== 13 | 14 | * :ref:`genindex` 15 | * :ref:`modindex` 16 | * :ref:`search` 17 | -------------------------------------------------------------------------------- /spec/session.yml: -------------------------------------------------------------------------------- 1 | Session: 2 | title: Session 3 | type: object 4 | properties: 5 | system: 6 | description: Session system 7 | type: string 8 | browser: 9 | description: Session browser 10 | type: string 11 | screen: 12 | description: Session screen 13 | type: string 14 | required: 15 | - system 16 | - browser 17 | - screen 18 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/ws/test_user.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.gen_test 5 | async def test_user_entity_should_be_available_over_websocket( 6 | user_push, user): 7 | response = await user_push({'channel': 'user.me'}) 8 | result = response.json() 9 | assert result['channel'] == 'user.me' 10 | assert result['body']['id'] == str(user.id) 11 | assert result['body'].pop('accounts') 12 | -------------------------------------------------------------------------------- /spec/queue.yml: -------------------------------------------------------------------------------- 1 | Queue: 2 | type: object 3 | tags: 4 | - draft 5 | properties: 6 | id: 7 | description: Queue id 8 | type: string 9 | account_id: 10 | $ref: 'account.yml#/Account/properties/id' 11 | playbacks: 12 | description: List of playbacks 13 | items: 14 | $ref: 'playback.yml#/Playback' 15 | type: array 16 | required: 17 | - id 18 | - account_id 19 | - playbacks 20 | -------------------------------------------------------------------------------- /src/cloudplayer/api/controller/session.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.controller.session 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller import Controller 9 | from cloudplayer.api.model.session import Session 10 | 11 | 12 | class SessionController(Controller): 13 | 14 | __model__ = Session 15 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/upstream/youtube/search.json: -------------------------------------------------------------------------------- 1 | { 2 | "items": [ 3 | { 4 | "id": { 5 | "videoId": "PDZcqBgCS74" 6 | } 7 | }, 8 | { 9 | "id": { 10 | "videoId": "kK42LZqO0wA" 11 | } 12 | }, 13 | { 14 | "id": { 15 | "videoId": "yL6odWvurCI" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/ws/test_base.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.gen_test 5 | async def test_websocket_connection_responds_with_fallback(user_push): 6 | not_found = await user_push({'channel': 'cannot.be.found'}) 7 | assert not_found.json() == { 8 | 'channel': 'cannot.be.found', 9 | 'sequence': 0, 10 | 'body': { 11 | 'reason': 'channel not found', 12 | 'status_code': 404}} 13 | -------------------------------------------------------------------------------- /src/cloudplayer/api/controller/provider.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.controller.provider 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller import Controller 9 | from cloudplayer.api.model.provider import Provider 10 | 11 | 12 | class ProviderController(Controller): 13 | 14 | __model__ = Provider 15 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/controller/test_account.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from cloudplayer.api.controller.account import AccountController 4 | 5 | 6 | @pytest.mark.gen_test 7 | async def test_controller_should_redirect_alias_to_current_user( 8 | db, current_user): 9 | controller = AccountController(db, current_user) 10 | account = await controller.read({'id': 'me', 'provider_id': 'cloudplayer'}) 11 | assert account.id == current_user['cloudplayer'] 12 | -------------------------------------------------------------------------------- /spec/token.yml: -------------------------------------------------------------------------------- 1 | Token: 2 | title: Token 3 | type: object 4 | properties: 5 | id: 6 | description: Token id 7 | type: string 8 | claimed: 9 | description: Claim status 10 | type: boolean 11 | default: False 12 | account_id: 13 | $ref: 'account.yml#/Account/properties/id' 14 | account_provider_id: 15 | $ref: 'provider.yml#/Provider/properties/id' 16 | required: 17 | - id 18 | - claimed 19 | - account_id 20 | - account_provider_id 21 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/controller/test_favourite.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from cloudplayer.api.controller.favourite import FavouriteController 4 | 5 | 6 | @pytest.mark.gen_test 7 | async def test_favourite_controller_should_redirect_mine_alias( 8 | db, current_user): 9 | controller = FavouriteController(db, current_user) 10 | ids = {'id': 'mine', 'provider_id': 'cloudplayer'} 11 | favourite = await controller.read(ids) 12 | assert favourite.account_id == current_user['cloudplayer'] 13 | -------------------------------------------------------------------------------- /src/cloudplayer/api/ws/user.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.ws.user 3 | ~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.user import UserController 9 | from cloudplayer.api.handler import EntityMixin 10 | from cloudplayer.api.ws import WSHandler 11 | 12 | 13 | class Entity(EntityMixin, WSHandler): 14 | 15 | __controller__ = UserController 16 | 17 | SUPPORTED_METHODS = ('GET',) 18 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/user.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.user 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.user import UserController 9 | from cloudplayer.api.handler import EntityMixin 10 | from cloudplayer.api.http import HTTPHandler 11 | 12 | 13 | class Entity(EntityMixin, HTTPHandler): 14 | 15 | __controller__ = UserController 16 | 17 | SUPPORTED_METHODS = ('GET',) 18 | -------------------------------------------------------------------------------- /spec/user.yml: -------------------------------------------------------------------------------- 1 | User: 2 | title: User 3 | type: object 4 | properties: 5 | id: 6 | description: User id 7 | type: string 8 | accounts: 9 | description: Connected accounts 10 | type: array 11 | items: 12 | $ref: 'account.yml#/ListedAccount' 13 | created: 14 | description: User creation 15 | format: date-time 16 | type: string 17 | updated: 18 | description: User update 19 | format: date-time 20 | type: string 21 | required: 22 | - id 23 | - accounts 24 | -------------------------------------------------------------------------------- /src/cloudplayer/api/ws/account.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.ws.account 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.account import AccountController 9 | from cloudplayer.api.handler import EntityMixin 10 | from cloudplayer.api.ws import WSHandler 11 | 12 | 13 | class Entity(EntityMixin, WSHandler): 14 | 15 | __controller__ = AccountController 16 | 17 | SUPPORTED_METHODS = ('GET', 'SUB', 'UNSUB') 18 | -------------------------------------------------------------------------------- /spec/history.yml: -------------------------------------------------------------------------------- 1 | History: 2 | type: object 3 | tags: 4 | - draft 5 | properties: 6 | id: 7 | description: History id 8 | type: string 9 | account_id: 10 | $ref: 'account.yml#/Account/properties/id' 11 | account_provider_id: 12 | $ref: 'provider.yml#/Provider/properties/id' 13 | playbacks: 14 | description: List of playbacks 15 | items: 16 | $ref: 'playback.yml#/Playback' 17 | type: array 18 | required: 19 | - id 20 | - account_id 21 | - account_provider_id 22 | - playbacks 23 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/account.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.account 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.account import AccountController 9 | from cloudplayer.api.http import HTTPHandler 10 | from cloudplayer.api.handler import EntityMixin 11 | 12 | 13 | class Entity(EntityMixin, HTTPHandler): 14 | 15 | __controller__ = AccountController 16 | 17 | SUPPORTED_METHODS = ('GET', 'OPTIONS') 18 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - prep 3 | - test 4 | 5 | services: 6 | - postgres:10.4 7 | - redis:4.0 8 | 9 | variables: 10 | POSTGRES_DB: cloudplayer 11 | POSTGRES_USER: api 12 | POSTGRES_PASSWORD: password 13 | 14 | prep: 15 | stage: prep 16 | script: 17 | - apt-get install software-properties-common python-software-properties 18 | - add-apt-repository -y ppa:jonathonf/python-3.6 19 | - apt-get update 20 | - apt-get install python3.6 21 | 22 | test: 23 | stage: test 24 | script: 25 | - make install 26 | - pytest 27 | -------------------------------------------------------------------------------- /src/cloudplayer/api/ws/playlist.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.ws.playlist 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.playlist import PlaylistController 9 | from cloudplayer.api.handler import EntityMixin 10 | from cloudplayer.api.ws import WSHandler 11 | 12 | 13 | class Entity(EntityMixin, WSHandler): 14 | 15 | __controller__ = PlaylistController 16 | 17 | SUPPORTED_METHODS = ('GET', 'PUT', 'SUB', 'UNSUB') 18 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/favourite.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.favourite 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.favourite import FavouriteController 9 | from cloudplayer.api.handler import EntityMixin 10 | from cloudplayer.api.http import HTTPHandler 11 | 12 | 13 | class Entity(EntityMixin, HTTPHandler): 14 | 15 | __controller__ = FavouriteController 16 | 17 | SUPPORTED_METHODS = ('GET', 'OPTIONS') 18 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/session.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.session 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.session import SessionController 9 | from cloudplayer.api.handler import CollectionMixin 10 | from cloudplayer.api.http import HTTPHandler 11 | 12 | 13 | class Collection(CollectionMixin, HTTPHandler): 14 | 15 | __controller__ = SessionController 16 | 17 | SUPPORTED_METHODS = ('POST', 'OPTIONS') 18 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/http/test_favourite.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.gen_test 5 | async def test_favourite_id_should_be_referenced_in_account( 6 | account, user_fetch): 7 | response = await user_fetch( 8 | '/account/cloudplayer/{}'.format(account.id)) 9 | favourite_id = response.json().get('favourite_id') 10 | assert favourite_id == account.favourite.id 11 | response = await user_fetch( 12 | '/favourite/cloudplayer/{}'.format(favourite_id)) 13 | account_id = response.json().get('account_id') 14 | assert account_id == account.id 15 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/model/test_user.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sql 2 | 3 | from cloudplayer.api.model.user import User 4 | from cloudplayer.api.model.base import Base 5 | 6 | 7 | def test_user_model_should_create_table(db): 8 | user = sql.Table( 9 | 'user', Base.metadata, autoload=True, 10 | autoload_with=db) 11 | assert user.exists(db.connection()) 12 | 13 | 14 | def test_user_model_can_be_created(db): 15 | user = User() 16 | db.add(user) 17 | db.commit() 18 | user_id = user.id 19 | db.expunge(user) 20 | assert db.query(User).get(user_id) 21 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/controller/test_track.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.gen_test 5 | def test_soundcloud_track_controller_should_retrieve_and_convert_tracks( 6 | user_fetch, expect): 7 | response = yield user_fetch('/track/soundcloud?q=test') 8 | assert response.json() == expect('/tracks/soundcloud') 9 | 10 | 11 | @pytest.mark.gen_test 12 | def test_youtube_track_controller_should_retrieve_and_convert_tracks( 13 | user_fetch, expect): 14 | response = yield user_fetch('/track/youtube?q=test') 15 | assert response.json() == expect('/tracks/youtube') 16 | -------------------------------------------------------------------------------- /spec/favourite.yml: -------------------------------------------------------------------------------- 1 | Favourite: 2 | type: object 3 | properties: 4 | id: 5 | description: Favourite id 6 | type: string 7 | provider_id: 8 | $ref: 'provider.yml#/Provider/properties/id' 9 | account_id: 10 | $ref: 'account.yml#/Account/properties/id' 11 | account_provider_id: 12 | $ref: 'provider.yml#/Provider/properties/id' 13 | items: 14 | description: Favourite items 15 | items: 16 | $ref: 'favourite_item.yml#/FavouriteItem' 17 | type: array 18 | required: 19 | - id 20 | - provider_id 21 | - account_id 22 | - account_provider_id 23 | - items 24 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/track_comment.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.track_comment 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.track_comment import \ 9 | SoundcloudTrackCommentController 10 | from cloudplayer.api.handler import CollectionMixin 11 | from cloudplayer.api.http import HTTPHandler 12 | 13 | 14 | class Soundcloud(CollectionMixin, HTTPHandler): 15 | 16 | __controller__ = SoundcloudTrackCommentController 17 | 18 | SUPPORTED_METHODS = ('GET', 'OPTIONS') 19 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/http/test_session.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.gen_test 5 | async def test_account_session_can_be_created_with_fingerprint( 6 | user_fetch, account, db): 7 | assert not account.sessions 8 | body = {'system': 'windows', 'browser': 'safari', 'screen': '1024x800'} 9 | response = await user_fetch('/session', method='POST', body=body) 10 | assert response.json() == {} 11 | db.refresh(account) 12 | assert account.sessions 13 | session = account.sessions[0] 14 | assert session.system == 'windows' 15 | assert session.browser == 'safari' 16 | assert session.screen == '1024x800' 17 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/model/test_image.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sql 2 | 3 | from cloudplayer.api.model.image import Image 4 | import cloudplayer.api.model.base as model 5 | 6 | 7 | def test_image_model_should_create_table(db): 8 | image = sql.Table( 9 | 'image', model.Base.metadata, autoload=True, 10 | autoload_with=db) 11 | assert image.exists(db.connection()) 12 | 13 | 14 | def test_image_model_can_be_created(db): 15 | image = Image( 16 | large='http://img.host/large.jpg') 17 | db.add(image) 18 | db.commit() 19 | image_id = image.id 20 | db.expunge(image) 21 | assert db.query(Image).get(image_id) 22 | -------------------------------------------------------------------------------- /scripts/init-database.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS dblink; 2 | 3 | DO 4 | $do$ 5 | BEGIN 6 | IF NOT EXISTS ( 7 | SELECT 8 | FROM pg_database 9 | WHERE datname = 'cloudplayer') THEN 10 | PERFORM dblink_exec('dbname=' || current_database(), 'CREATE DATABASE cloudplayer'); 11 | END IF; 12 | END 13 | $do$; 14 | 15 | DO 16 | $do$ 17 | BEGIN 18 | IF NOT EXISTS ( 19 | SELECT 20 | FROM pg_catalog.pg_user 21 | WHERE usename = 'api') THEN 22 | CREATE ROLE api LOGIN PASSWORD 'password'; 23 | GRANT ALL PRIVILEGES ON DATABASE cloudplayer TO api; 24 | END IF; 25 | END 26 | $do$; 27 | -------------------------------------------------------------------------------- /src/cloudplayer/api/access/__init__.py: -------------------------------------------------------------------------------- 1 | from .action import Anything, Create, Delete, Query, Read, Update 2 | from .fields import Available, Fields 3 | from .policy import Policy, PolicyViolation 4 | from .principal import Child, Everyone, Owner, Parent 5 | from .rule import Allow, Deny 6 | 7 | __all__ = [ 8 | 'Allow', 9 | 'Anything', 10 | 'Available', 11 | 'Child', 12 | 'Create', 13 | 'Delete', 14 | 'Deny', 15 | 'Everyone', 16 | 'Fields', 17 | 'Owner', 18 | 'Parent', 19 | 'Policy', 20 | 'PolicyViolation', 21 | 'Query', 22 | 'Read', 23 | 'Update' 24 | ] 25 | __import__('pkg_resources').declare_namespace(__name__) 26 | -------------------------------------------------------------------------------- /appspec.yml: -------------------------------------------------------------------------------- 1 | version: 0.0 2 | os: linux 3 | hooks: 4 | AfterInstall: 5 | - location: scripts/install-as-root.sh 6 | timeout: 300 7 | runas: root 8 | - location: scripts/install-as-user.sh 9 | timeout: 600 10 | runas: cloudplayer 11 | - location: scripts/init-database.sh 12 | timeout: 100 13 | runas: postgres 14 | ApplicationStart: 15 | - location: scripts/run.sh 16 | timeout: 120 17 | runas: root 18 | ApplicationStop: 19 | - location: scripts/stop.sh 20 | timeout: 120 21 | runas: root 22 | ValidateService: 23 | - location: scripts/validate.sh 24 | timeout: 60 25 | runas: root 26 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/controller/test_favourite_item.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from cloudplayer.api.controller import ControllerException 4 | from cloudplayer.api.controller.favourite_item import FavouriteItemController 5 | 6 | 7 | @pytest.mark.gen_test 8 | async def test_favourite_item_controller_should_404_if_favourite_is_missing( 9 | db, current_user): 10 | controller = FavouriteItemController(db, current_user) 11 | ids = {'favourite_id': 'missing', 'favourite_provider_id': 'cloudplayer'} 12 | with pytest.raises(ControllerException) as error: 13 | await controller.read(ids, {'favourite_id': 'missing'}) 14 | assert error.value.status_code == 404 15 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/controller/test_playlist_item.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from cloudplayer.api.controller import ControllerException 4 | from cloudplayer.api.controller.playlist_item import PlaylistItemController 5 | 6 | 7 | @pytest.mark.gen_test 8 | async def test_playlist_item_controller_should_404_if_playlist_is_missing( 9 | db, current_user): 10 | controller = PlaylistItemController(db, current_user) 11 | ids = {'playlist_id': 'something', 'playlist_provider_id': 'cloudplayer'} 12 | with pytest.raises(ControllerException) as error: 13 | await controller.read(ids, {'playlist_id': 'something else'}) 14 | assert error.value.status_code == 404 15 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/provider.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.provider 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.provider import ProviderController 9 | from cloudplayer.api.handler import CollectionMixin, EntityMixin 10 | from cloudplayer.api.http import HTTPHandler 11 | 12 | 13 | class Entity(EntityMixin, HTTPHandler): 14 | 15 | __controller__ = ProviderController 16 | 17 | SUPPORTED_METHODS = ('GET',) 18 | 19 | 20 | class Collection(CollectionMixin, HTTPHandler): 21 | 22 | __controller__ = ProviderController 23 | 24 | SUPPORTED_METHODS = ('GET',) 25 | -------------------------------------------------------------------------------- /src/cloudplayer/api/controller/account.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.controller.account 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.access import Available 9 | from cloudplayer.api.controller import Controller 10 | from cloudplayer.api.model.account import Account 11 | 12 | 13 | class AccountController(Controller): 14 | 15 | __model__ = Account 16 | 17 | async def read(self, ids, fields=Available): 18 | if ids['id'] == 'me': 19 | ids['id'] = self.current_user.get(ids['provider_id'], 'me') 20 | entity = await super().read(ids) 21 | return entity 22 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/model/test_token.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sql 2 | 3 | from cloudplayer.api.model.token import Token 4 | import cloudplayer.api.model.base as model 5 | 6 | 7 | def test_token_model_should_create_table(db): 8 | token = sql.Table( 9 | 'token', model.Base.metadata, autoload=True, 10 | autoload_with=db) 11 | assert token.exists(db.connection()) 12 | 13 | 14 | def test_token_model_can_be_created(current_user, db): 15 | token = Token( 16 | account_id=current_user['cloudplayer'], 17 | account_provider_id='cloudplayer') 18 | db.add(token) 19 | db.commit() 20 | token_id = token.id 21 | db.expunge(token) 22 | assert db.query(Token).get(token_id) 23 | -------------------------------------------------------------------------------- /spec/image.yml: -------------------------------------------------------------------------------- 1 | EmbeddedImage: 2 | type: object 3 | properties: 4 | id: 5 | description: Image id 6 | type: string 7 | large: 8 | description: Large uri 9 | type: string 10 | medium: 11 | description: Medium uri 12 | type: string 13 | small: 14 | description: Small uri 15 | type: string 16 | required: 17 | - id 18 | - large 19 | 20 | Image: 21 | allOf: 22 | - $ref: '#/EmbeddedImage' 23 | - type: object 24 | properties: 25 | created: 26 | description: Image creation 27 | format: date-time 28 | type: string 29 | updated: 30 | description: Image update 31 | format: date-time 32 | type: string 33 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/token.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.token 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.token import TokenController 9 | from cloudplayer.api.handler import CollectionMixin, EntityMixin 10 | from cloudplayer.api.http import HTTPHandler 11 | 12 | 13 | class Entity(EntityMixin, HTTPHandler): 14 | 15 | __controller__ = TokenController 16 | 17 | SUPPORTED_METHODS = ('GET', 'PUT', 'OPTIONS') 18 | 19 | 20 | class Collection(CollectionMixin, HTTPHandler): 21 | 22 | __controller__ = TokenController 23 | 24 | SUPPORTED_METHODS = ('POST', 'OPTIONS') 25 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/model/test_account.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sql 2 | 3 | from cloudplayer.api.model.account import Account 4 | import cloudplayer.api.model.base as model 5 | 6 | 7 | def test_account_model_should_create_table(db): 8 | account = sql.Table( 9 | 'account', model.Base.metadata, autoload=True, 10 | autoload_with=db) 11 | assert account.exists(db.connection()) 12 | 13 | 14 | def test_account_model_can_be_created(current_user, db): 15 | account = Account( 16 | id='abcd1234', 17 | user_id=current_user['user_id'], 18 | provider_id='cloudplayer') 19 | db.add(account) 20 | db.commit() 21 | db.expunge(account) 22 | assert db.query(Account).get(('abcd1234', 'cloudplayer')) 23 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/playlist.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.playlist 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.playlist import PlaylistController 9 | from cloudplayer.api.handler import CollectionMixin, EntityMixin 10 | from cloudplayer.api.http import HTTPHandler 11 | 12 | 13 | class Entity(EntityMixin, HTTPHandler): 14 | 15 | __controller__ = PlaylistController 16 | 17 | SUPPORTED_METHODS = ('GET', 'PATCH', 'DELETE', 'OPTIONS') 18 | 19 | 20 | class Collection(CollectionMixin, HTTPHandler): 21 | 22 | __controller__ = PlaylistController 23 | 24 | SUPPORTED_METHODS = ('GET', 'POST', 'OPTIONS') 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | @python3.6 -m venv --upgrade --copies . 3 | bin/pip3.6 install -e .[test,doc] 4 | 5 | sphinx: 6 | bin/sphinx-build -N -b singlehtml source build 7 | 8 | shins: 9 | @npm i -g shins widdershins 10 | @mkdir -p ./shins 11 | widdershins -e widdershins.json --omitBody --resolve spec/restapi.yml source/restapi.html.md 12 | shins --minify --inline --logo source/images/logo.png -o shins/restapi.html source/restapi.html.md 13 | widdershins -e widdershins.json --omitBody --resolve spec/asyncapi.yml source/asyncapi.html.md 14 | shins --minify --inline --logo source/images/logo.png -o shins/asyncapi.html source/asyncapi.html.md 15 | @grep -v "X509" shins/asyncapi.html > temp && mv temp shins/asyncapi.html 16 | 17 | .DEFAULT: install sphinx shins 18 | .PHONY: install sphinx shins 19 | -------------------------------------------------------------------------------- /spec/track_comment.yml: -------------------------------------------------------------------------------- 1 | TrackComment: 2 | type: object 3 | properties: 4 | id: 5 | description: Track comment id 6 | type: string 7 | provider_id: 8 | $ref: 'provider.yml#/Provider/properties/id' 9 | account: 10 | $ref: 'account.yml#/EmbeddedAccount' 11 | body: 12 | description: Track comment body 13 | type: string 14 | timestamp: 15 | description: Track comment timestamp 16 | format: int32 17 | type: integer 18 | track_id: 19 | $ref: 'track.yml#/Track/properties/id' 20 | track_provider_id: 21 | $ref: 'provider.yml#/Provider/properties/id' 22 | created: 23 | description: Track comment creation 24 | format: date-time 25 | type: string 26 | required: 27 | - id 28 | - provider_id 29 | -------------------------------------------------------------------------------- /src/cloudplayer/api/controller/favourite.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.controller.favourite 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.access import Available 9 | from cloudplayer.api.controller import Controller 10 | from cloudplayer.api.model.favourite import Favourite 11 | 12 | 13 | class FavouriteController(Controller): 14 | 15 | __model__ = Favourite 16 | 17 | async def read(self, ids, fields=Available): 18 | if ids['id'] == 'mine': 19 | account = self.get_account(ids['provider_id']) 20 | if account: 21 | ids['id'] = account.favourite.id 22 | return await super().read(ids, fields=fields) 23 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/playlist_item.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.playlist_item 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.playlist_item import PlaylistItemController 9 | from cloudplayer.api.handler import CollectionMixin, EntityMixin 10 | from cloudplayer.api.http import HTTPHandler 11 | 12 | 13 | class Entity(EntityMixin, HTTPHandler): 14 | 15 | __controller__ = PlaylistItemController 16 | 17 | SUPPORTED_METHODS = ('GET', 'PATCH', 'DELETE', 'OPTIONS') 18 | 19 | 20 | class Collection(CollectionMixin, HTTPHandler): 21 | 22 | __controller__ = PlaylistItemController 23 | 24 | SUPPORTED_METHODS = ('GET', 'POST', 'OPTIONS') 25 | -------------------------------------------------------------------------------- /src/cloudplayer/api/controller/user.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.controller.user 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller import Controller, ControllerException 9 | from cloudplayer.api.model.user import User 10 | 11 | 12 | class UserController(Controller): 13 | 14 | __model__ = User 15 | 16 | async def read(self, ids): 17 | if ids['id'] == 'me': 18 | ids['id'] = self.current_user['user_id'] 19 | try: 20 | entity = await super().read(ids) 21 | except ControllerException: 22 | if ids['id'] == self.current_user['user_id']: 23 | self.current_user.clear() 24 | raise 25 | return entity 26 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/model/test_favourite.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sql 2 | 3 | from cloudplayer.api.model.favourite import Favourite 4 | import cloudplayer.api.model.base as model 5 | 6 | 7 | def test_favourite_model_should_create_table(db): 8 | favourite = sql.Table( 9 | 'favourite', model.Base.metadata, autoload=True, 10 | autoload_with=db) 11 | assert favourite.exists(db.connection()) 12 | 13 | 14 | def test_favourite_model_can_be_created(current_user, db): 15 | favourite = Favourite( 16 | provider_id='cloudplayer', 17 | account_id=current_user['cloudplayer'], 18 | account_provider_id='cloudplayer') 19 | db.add(favourite) 20 | db.commit() 21 | favourite_id = favourite.id 22 | db.expunge(favourite) 23 | assert db.query(Favourite).get((favourite_id, 'cloudplayer')) 24 | -------------------------------------------------------------------------------- /spec/playback.yml: -------------------------------------------------------------------------------- 1 | Playback: 2 | type: object 3 | tags: 4 | - draft 5 | properties: 6 | id: 7 | description: Playback id 8 | type: string 9 | previous: 10 | $ref: '#/Playback/properties/id' 11 | next: 12 | $ref: '#/Playback/properties/id' 13 | progress: 14 | description: Playback progress 15 | format: double 16 | type: number 17 | status: 18 | description: Playback status 19 | type: string 20 | enum: 21 | - completed 22 | - skipped 23 | - current 24 | - queued 25 | - suggested 26 | created: 27 | description: Playback creation 28 | format: date-time 29 | type: string 30 | updated: 31 | description: Playback update 32 | format: date-time 33 | type: string 34 | required: 35 | - id 36 | - status 37 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/model/test_playlist.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sql 2 | 3 | from cloudplayer.api.model.playlist import Playlist 4 | import cloudplayer.api.model.base as model 5 | 6 | 7 | def test_playlist_model_should_create_table(db): 8 | playlist = sql.Table( 9 | 'playlist', model.Base.metadata, autoload=True, 10 | autoload_with=db) 11 | assert playlist.exists(db.connection()) 12 | 13 | 14 | def test_playlist_model_can_be_created(current_user, db): 15 | playlist = Playlist( 16 | provider_id='cloudplayer', 17 | account_id=current_user['cloudplayer'], 18 | account_provider_id='cloudplayer', 19 | title='5678-abcd') 20 | db.add(playlist) 21 | db.commit() 22 | playlist_id = playlist.id 23 | db.expunge(playlist) 24 | assert db.query(Playlist).get((playlist_id, 'cloudplayer')) 25 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/controller/test_user.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from cloudplayer.api.controller import ControllerException 4 | from cloudplayer.api.controller.user import UserController 5 | 6 | 7 | @pytest.mark.gen_test 8 | async def test_controller_should_redirect_alias_to_current_user( 9 | db, current_user): 10 | controller = UserController(db, current_user) 11 | user = await controller.read({'id': 'me'}) 12 | assert user.id == current_user['user_id'] 13 | 14 | 15 | @pytest.mark.gen_test 16 | async def test_controller_should_clear_current_user_on_id_missing( 17 | db, current_user): 18 | current_user['user_id'] = 999123999456 19 | controller = UserController(db, current_user) 20 | with pytest.raises(ControllerException): 21 | await controller.read({'id': current_user['user_id']}) 22 | assert current_user == {} 23 | -------------------------------------------------------------------------------- /spec/playlist_item.yml: -------------------------------------------------------------------------------- 1 | PlaylistItem: 2 | type: object 3 | properties: 4 | id: 5 | description: Playlist item id 6 | type: string 7 | playlist_id: 8 | $ref: 'playlist.yml#/Playlist/properties/id' 9 | playlist_provider_id: 10 | $ref: 'provider.yml#/Provider/properties/id' 11 | rank: 12 | description: Playlist item rank 13 | track_id: 14 | $ref: 'track.yml#/Track/properties/id' 15 | track_provider_id: 16 | $ref: 'provider.yml#/Provider/properties/id' 17 | created: 18 | description: Playlist item creation 19 | format: date-time 20 | type: string 21 | updated: 22 | description: Playlist item update 23 | format: date-time 24 | type: string 25 | required: 26 | - id 27 | - playlist_id 28 | - playlist_provider_id 29 | - rank 30 | - track_provider_id 31 | - track_id 32 | -------------------------------------------------------------------------------- /spec/favourite_item.yml: -------------------------------------------------------------------------------- 1 | favouriteItem: 2 | type: object 3 | properties: 4 | id: 5 | description: favourite item id 6 | type: string 7 | favourite_id: 8 | $ref: 'favourite.yml#/Favourite/properties/id' 9 | favourite_provider_id: 10 | $ref: 'provider.yml#/Provider/properties/id' 11 | rank: 12 | description: favourite item rank 13 | track_id: 14 | $ref: 'track.yml#/Track/properties/id' 15 | track_provider_id: 16 | $ref: 'provider.yml#/Provider/properties/id' 17 | created: 18 | description: favourite item creation 19 | format: date-time 20 | type: string 21 | updated: 22 | description: favourite item update 23 | format: date-time 24 | type: string 25 | required: 26 | - id 27 | - favourite_id 28 | - favourite_provider_id 29 | - rank 30 | - track_provider_id 31 | - track_id 32 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/tracklist_item.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.tracklist_item 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import sqlalchemy as sql 9 | import sqlalchemy.orm as orm 10 | from sqlalchemy.ext.declarative import declared_attr 11 | 12 | 13 | class TracklistItemMixin(object): 14 | 15 | id = sql.Column(sql.Integer) 16 | 17 | account_provider_id = sql.Column(sql.String(16), nullable=False) 18 | account_id = sql.Column(sql.String(32), nullable=False) 19 | 20 | @declared_attr 21 | def account(cls): 22 | return orm.relation( 23 | 'Account', 24 | single_parent=True) 25 | 26 | track_provider_id = sql.Column(sql.String(16), nullable=False) 27 | track_id = sql.Column(sql.String(128), nullable=False) 28 | -------------------------------------------------------------------------------- /src/cloudplayer/api/routing.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.routing 3 | ~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import re 9 | 10 | from tornado.routing import Matcher 11 | from tornado.util import basestring_type 12 | 13 | 14 | class ProtocolMatches(Matcher): 15 | """Matches requests based on `protocol_pattern` regex.""" 16 | 17 | def __init__(self, protocol_pattern): 18 | if isinstance(protocol_pattern, basestring_type): 19 | if not protocol_pattern.endswith('$'): 20 | protocol_pattern += '$' 21 | self.protocol_pattern = re.compile(protocol_pattern) 22 | else: 23 | self.protocol_pattern = protocol_pattern 24 | 25 | def match(self, request): 26 | if self.protocol_pattern.match(request.protocol): 27 | return {} 28 | return None 29 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/model/test_provider.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sql 2 | 3 | from cloudplayer.api.model.provider import Provider 4 | import cloudplayer.api.model.base as model 5 | 6 | 7 | def test_provider_model_should_create_table(db): 8 | provider = sql.Table( 9 | 'provider', model.Base.metadata, autoload=True, 10 | autoload_with=db) 11 | assert provider.exists(db.connection()) 12 | 13 | 14 | def test_provider_model_can_be_created(db): 15 | provider = Provider( 16 | id='abcd') 17 | db.add(provider) 18 | db.commit() 19 | db.expunge(provider) 20 | assert db.query(Provider).get('abcd') 21 | 22 | 23 | def test_provider_should_provider_access_to_api_keys(db): 24 | dummy = Provider(id='dummy') 25 | db.add(dummy) 26 | db.commit() 27 | assert dummy.client_id is None 28 | cloudplayer = db.query(Provider).get('cloudplayer') 29 | assert cloudplayer.client_id == 'cp-api-key' 30 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/access/test_action.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from cloudplayer.api.access import (Anything, Create, Delete, Query, Read, 4 | Update) 5 | 6 | 7 | def test_action_should_bind_to_target(): 8 | target = mock.Mock() 9 | action = Anything(target) 10 | assert action._target is target 11 | 12 | 13 | def test_actions_for_extended_crud_are_available_in_module(): 14 | assert Anything() 15 | assert Create() 16 | assert Read() 17 | assert Update() 18 | assert Delete() 19 | assert Query() 20 | 21 | 22 | def test_anything_action_should_equal_anything_else(): 23 | assert Anything() == Anything() 24 | assert Anything() == Create() 25 | assert Anything() == object() 26 | 27 | 28 | def test_operations_should_equal_only_same_action_classes(): 29 | assert Create() == Create() 30 | assert Create() != Delete() 31 | assert Create() != object() 32 | -------------------------------------------------------------------------------- /scripts/install-as-root.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ex 3 | 4 | # create cloudplayer service user 5 | id -u cloudplayer &> /dev/null || useradd -m -U -d /srv/cloudplayer cloudplayer 6 | 7 | # update and install dependencies 8 | add-apt-repository -y \ 9 | ppa:jonathonf/python-3.6 10 | apt-get update 11 | apt-get install -y \ 12 | redis-server \ 13 | supervisor \ 14 | nginx \ 15 | htop \ 16 | python3.6 \ 17 | python3.6-venv \ 18 | python3.6-dev \ 19 | python3-pycurl \ 20 | python-pip \ 21 | python-pip-whl \ 22 | libcurl4-openssl-dev \ 23 | libssl-dev \ 24 | postgresql 25 | 26 | # enable nginx site 27 | rm /etc/nginx/sites-enabled/default &> /dev/null || echo "default disabled" 28 | cp cloudplayer.conf /etc/nginx/sites-enabled/cloudplayer.conf 29 | 30 | # copy supervisor config 31 | cp api.conf /etc/supervisor/conf.d/api.conf 32 | 33 | # copy current app distribution 34 | mkdir -p /srv/cloudplayer 35 | cp -r . /srv/cloudplayer 36 | chown -R cloudplayer:cloudplayer /srv/cloudplayer 37 | -------------------------------------------------------------------------------- /spec/track.yml: -------------------------------------------------------------------------------- 1 | Track: 2 | type: object 3 | properties: 4 | id: 5 | description: Track id 6 | type: string 7 | provider_id: 8 | $ref: 'provider.yml#/Provider/properties/id' 9 | account: 10 | $ref: 'account.yml#/EmbeddedAccount' 11 | aspect_ratio: 12 | description: Player aspect ratio 13 | format: double 14 | type: number 15 | duration: 16 | description: Track duration 17 | format: int32 18 | type: integer 19 | favourite_count: 20 | description: Favourite count 21 | format: int32 22 | type: integer 23 | image: 24 | $ref: 'image.yml#/EmbeddedImage' 25 | play_count: 26 | description: Play count 27 | format: int32 28 | type: integer 29 | title: 30 | description: Track title 31 | type: string 32 | created: 33 | description: Track creation 34 | format: date-time 35 | type: string 36 | required: 37 | - id 38 | - provider_id 39 | - account 40 | - title 41 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/favourite.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.favourite 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import sqlalchemy.orm as orm 9 | 10 | from cloudplayer.api.access import Allow, Fields, Owner, Read 11 | from cloudplayer.api.model import Base 12 | from cloudplayer.api.model.tracklist import TracklistMixin 13 | 14 | 15 | class Favourite(TracklistMixin, Base): 16 | 17 | __acl__ = ( 18 | Allow(Owner, Read, Fields( 19 | 'id', 20 | 'provider_id', 21 | 'account_id', 22 | 'account_provider_id' 23 | )), 24 | ) 25 | 26 | account = orm.relation( 27 | 'Account', 28 | back_populates='favourite', 29 | viewonly=True) 30 | 31 | items = orm.relation( 32 | 'FavouriteItem', 33 | cascade='all, delete-orphan', 34 | order_by='FavouriteItem.created', 35 | single_parent=True) 36 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/provider.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.provider 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import sqlalchemy as sql 9 | import sqlalchemy.orm as orm 10 | import tornado.options as opt 11 | 12 | from cloudplayer.api.access import Allow, Everyone, Fields, Query, Read 13 | from cloudplayer.api.model import Base 14 | 15 | 16 | class Provider(Base): 17 | 18 | __acl__ = ( 19 | Allow(Everyone, Read, Fields( 20 | 'id', 21 | 'client_id' 22 | )), 23 | Allow(Everyone, Query, Fields('id')) 24 | ) 25 | __table_args__ = ( 26 | sql.PrimaryKeyConstraint( 27 | 'id'), 28 | ) 29 | 30 | id = sql.Column(sql.String(12)) 31 | 32 | provider_id = orm.synonym('id') 33 | 34 | @property 35 | def client_id(self): 36 | if self.id in opt.options['providers']: 37 | return opt.options[self.id]['api_key'] 38 | -------------------------------------------------------------------------------- /spec/account.yml: -------------------------------------------------------------------------------- 1 | EmbeddedAccount: 2 | type: object 3 | properties: 4 | id: 5 | description: Account id 6 | type: string 7 | provider_id: 8 | $ref: 'provider.yml#/Provider/properties/id' 9 | image: 10 | $ref: 'image.yml#/EmbeddedImage' 11 | title: 12 | description: Account title 13 | type: string 14 | required: 15 | - id 16 | - provider_id 17 | 18 | ListedAccount: 19 | allOf: 20 | - $ref: '#/EmbeddedAccount' 21 | - type: object 22 | properties: 23 | connected: 24 | description: Connection status 25 | type: boolean 26 | 27 | Account: 28 | allOf: 29 | - $ref: '#/ListedAccount' 30 | - type: object 31 | properties: 32 | user_id: 33 | $ref: 'user.yml#/User/properties/id' 34 | favourite_id: 35 | $ref: 'favourite.yml#/Favourite/properties/id' 36 | created: 37 | description: Account creation 38 | format: date-time 39 | type: string 40 | updated: 41 | description: Account update 42 | format: date-time 43 | type: string 44 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/track_comment.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.track_comment 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.access import Allow, Everyone, Read, Fields 9 | from cloudplayer.api.model import Transient 10 | 11 | 12 | class TrackComment(Transient): 13 | 14 | __acl__ = ( 15 | Allow(Everyone, Read, Fields( 16 | 'id', 17 | 'provider_id', 18 | 'account.id', 19 | 'account.provider_id', 20 | 'account.title', 21 | 'account.image.small', 22 | 'account.image.medium', 23 | 'account.image.large', 24 | 'body', 25 | 'timestamp', 26 | 'track_id', 27 | 'track_provider_id', 28 | 'created' 29 | )), 30 | ) 31 | 32 | id = None 33 | provider_id = None 34 | 35 | account = None 36 | 37 | body = None 38 | timestamp = None 39 | track_id = None 40 | track_provider_id = None 41 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/test_util.py: -------------------------------------------------------------------------------- 1 | from cloudplayer.api.util import gen_token, chunk_range, squeeze 2 | 3 | 4 | def test_token_generator_should_create_token(): 5 | token = gen_token(10, 'abc') 6 | assert len(token) == 10 7 | assert all([t in 'abc' for t in token]) 8 | 9 | 10 | def test_chunk_range_should_range_and_split_numbers(): 11 | assert chunk_range(0, 0, 0) == [(0, None)] 12 | assert chunk_range(3, 0, 0) == [(0, None)] 13 | assert chunk_range(3, 0, 1) == [(0, None)] 14 | assert chunk_range(3, 0, 2) == [(0, 2), (2, None)] 15 | assert chunk_range(3, 3, 2) == [(0, None)] 16 | assert chunk_range(3, 0, 3) == [(0, 1), (1, 2), (2, None)] 17 | assert chunk_range(3, 2, 3) == [(0, 2), (2, None)] 18 | assert chunk_range(3, 0, 4) == [(0, 1), (1, 2), (2, None)] 19 | 20 | 21 | def test_squeeze_should_reduce_string_to_non_whitespace_chars(): 22 | assert squeeze('') == '' 23 | assert squeeze('hello') == 'hello' 24 | assert squeeze('good bye') == 'goodbye' 25 | assert squeeze(""" 26 | multiple 27 | lines 28 | and words . 29 | """) == 'multiplelinesandwords.' 30 | -------------------------------------------------------------------------------- /src/cloudplayer/api/util.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.util 3 | ~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import math 9 | import multiprocessing 10 | import random 11 | import string 12 | 13 | 14 | def gen_token(n, alphabet=string.ascii_lowercase + string.digits): 15 | """Cryptographically sufficient token generator for length `n`.""" 16 | urand = random.SystemRandom() 17 | return ''.join(urand.choice(alphabet) for i in range(n)) 18 | 19 | 20 | def chunk_range(size, min_step=10, chunks=multiprocessing.cpu_count()): 21 | """Evenly chunk a range of `size` into `chunks` with `min_step`.""" 22 | size = max(size, 1) 23 | step = max(int(math.ceil(size / max(chunks, 1))), min_step) 24 | ranges = list(zip(range(0, size, step), range(step, size + step, step))) 25 | # Inject `None` as a slice operator to catch the last item 26 | ranges[-1] = (ranges[-1][0], None) 27 | return ranges 28 | 29 | 30 | def squeeze(string): 31 | """Squeezes any whitespaces or linebreaks out of `string`.""" 32 | return ''.join(string.split()) 33 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/model/test_favourite_item.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sql 2 | 3 | from cloudplayer.api.model.favourite import Favourite 4 | from cloudplayer.api.model.favourite_item import FavouriteItem 5 | import cloudplayer.api.model.base as model 6 | 7 | 8 | def test_favourite_item_model_should_create_table(db): 9 | favourite_item = sql.Table( 10 | 'favouriteitem', model.Base.metadata, autoload=True, 11 | autoload_with=db) 12 | assert favourite_item.exists(db.connection()) 13 | 14 | 15 | def test_favourite_item_model_can_be_created(current_user, db): 16 | favourite_item = FavouriteItem( 17 | favourite_provider_id='cloudplayer', 18 | favourite=Favourite( 19 | provider_id='cloudplayer', 20 | account_id=current_user['cloudplayer'], 21 | account_provider_id='cloudplayer'), 22 | account_provider_id='cloudplayer', 23 | account_id=current_user['cloudplayer'], 24 | track_provider_id='cloudplayer', 25 | track_id='abcd-1234') 26 | db.add(favourite_item) 27 | db.commit() 28 | favourite_item_id = favourite_item.id 29 | db.expunge_all() 30 | assert db.query(FavouriteItem).get(favourite_item_id) 31 | -------------------------------------------------------------------------------- /aws.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os.path 3 | __path__ = os.path.dirname(os.path.realpath(__file__)) 4 | sys.path.insert(0, __path__) 5 | 6 | debug = False 7 | xheaders = True 8 | static_path = '/srv/cloudplayer/static' 9 | redirect_state = 'v3' 10 | websocket_ping_interval = 10 11 | websocket_ping_timeout = 30 12 | 13 | providers = [ 14 | 'youtube', 15 | 'soundcloud', 16 | 'cloudplayer'] 17 | 18 | allowed_origins = [ 19 | 'https://cloud-player.io', 20 | 'http://localhost:8080', 21 | 'http://localhost:4200'] 22 | 23 | jwt_secret = 'secret' 24 | 25 | youtube = { 26 | 'key': 'key', 27 | 'api_key': 'api_key', 28 | 'secret': 'secret', 29 | 'redirect_uri': 'https://api.cloud-player.io/youtube'} 30 | 31 | soundcloud = { 32 | 'key': 'key', 33 | 'api_key': 'api_key', 34 | 'secret': 'secret', 35 | 'redirect_uri': 'https://api.cloud-player.io/soundcloud'} 36 | 37 | cloudplayer = { 38 | 'key': 'key', 39 | 'api_key': 'api_key', 40 | 'secret': 'secret', 41 | 'redirect_uri': 'https://api.cloud-player.io/cloudplayer'} 42 | 43 | bugsnag = { 44 | 'api_key': 'api_key', 45 | 'project_root': '/usr/local/cloudplayer' 46 | } 47 | 48 | try: 49 | from private import * 50 | except ImportError: 51 | pass 52 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/model/test_playlist_item.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy as sql 2 | 3 | from cloudplayer.api.model.playlist import Playlist 4 | from cloudplayer.api.model.playlist_item import PlaylistItem 5 | import cloudplayer.api.model.base as model 6 | 7 | 8 | def test_playlist_item_model_should_create_table(db): 9 | playlist_item = sql.Table( 10 | 'playlistitem', model.Base.metadata, autoload=True, 11 | autoload_with=db) 12 | assert playlist_item.exists(db.connection()) 13 | 14 | 15 | def test_playlist_item_model_can_be_created(current_user, db): 16 | playlist_item = PlaylistItem( 17 | rank='aaa', 18 | playlist_provider_id='cloudplayer', 19 | playlist=Playlist( 20 | provider_id='cloudplayer', 21 | account_id=current_user['cloudplayer'], 22 | account_provider_id='cloudplayer', 23 | title='5678-abcd'), 24 | account_provider_id='cloudplayer', 25 | account_id=current_user['cloudplayer'], 26 | track_provider_id='cloudplayer', 27 | track_id='abcd-1234') 28 | db.add(playlist_item) 29 | db.commit() 30 | playlist_item_id = playlist_item.id 31 | db.expunge_all() 32 | assert db.query(PlaylistItem).get(playlist_item_id) 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/Cloud-Player/api.svg?branch=master)](https://travis-ci.org/Cloud-Player/api) 2 | [![Openapi Documentation](https://img.shields.io/badge/doc-openapi-brightgreen.svg)](https://cloud-player.github.io/api/restapi.html) 3 | [![Asyncapi Documentation](https://img.shields.io/badge/doc-asyncapi-brightgreen.svg)](https://cloud-player.github.io/api/asyncapi.html) 4 | [![Code Coverage](https://codecov.io/gh/Cloud-Player/api/branch/master/graph/badge.svg)](https://codecov.io/gh/Cloud-Player/api) 5 | [![Python 3.6](https://img.shields.io/badge/python-3.6-blue.svg)](https://www.python.org/downloads) 6 | 7 | # Cloud Player API 8 | 9 | This repository contains the REST and ASYNC backend API for the Cloud-Player 10 | web and desktop applications. It is build using the tornado framework and integrates 11 | a PostgreSQL database over SQLAlchemy. The pub/sub system is implemented with Redis. 12 | There is a continuous testing and deployment toolchain setup with travis and AWS. 13 | 14 | ## require 15 | 16 | ``` 17 | python==3.6 18 | pip>=9 19 | npm>=3.5 20 | postgresql>=10 21 | redis>=3 22 | ``` 23 | 24 | ## install 25 | ``` 26 | make 27 | ``` 28 | 29 | ## develop 30 | ``` 31 | bin/api 32 | ``` 33 | 34 | ## test 35 | ``` 36 | bin/test 37 | ``` 38 | -------------------------------------------------------------------------------- /spec/playlist.yml: -------------------------------------------------------------------------------- 1 | Playlist: 2 | type: object 3 | properties: 4 | id: 5 | description: Playlist id 6 | type: string 7 | provider_id: 8 | $ref: 'provider.yml#/Provider/properties/id' 9 | account_id: 10 | $ref: 'account.yml#/Account/properties/id' 11 | account_provider_id: 12 | $ref: 'provider.yml#/Provider/properties/id' 13 | description: 14 | description: Playlist description 15 | type: string 16 | follower_count: 17 | description: Follower count 18 | format: int32 19 | type: integer 20 | image: 21 | $ref: 'image.yml#/EmbeddedImage' 22 | items: 23 | description: Playlist items 24 | items: 25 | $ref: 'playlist_item.yml#/PlaylistItem' 26 | type: array 27 | public: 28 | description: Public state 29 | type: boolean 30 | title: 31 | description: Playlist title 32 | type: string 33 | created: 34 | description: Playlist creation 35 | format: date-time 36 | type: string 37 | updated: 38 | description: Playlist update 39 | format: date-time 40 | type: string 41 | required: 42 | - id 43 | - provider_id 44 | - account_id 45 | - account_provider_id 46 | - items 47 | - title 48 | -------------------------------------------------------------------------------- /dev.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os.path 3 | __path__ = os.path.dirname(os.path.realpath(__file__)) 4 | sys.path.insert(0, __path__) 5 | 6 | debug = True 7 | xheaders = False 8 | static_path = 'static' 9 | redirect_state = 'dev' 10 | websocket_ping_interval = 5 11 | websocket_ping_timeout = 15 12 | 13 | providers = [ 14 | 'youtube', 15 | 'soundcloud', 16 | 'cloudplayer'] 17 | 18 | allowed_origins = [ 19 | 'http://localhost:4200', 20 | 'http://localhost:8040', 21 | 'http://localhost:8080'] 22 | 23 | jwt_secret = 'secret' 24 | public_scheme = 'http' 25 | public_domain = 'localhost' 26 | 27 | youtube = { 28 | 'key': 'key', 29 | 'api_key': 'api_key', 30 | 'secret': 'secret', 31 | 'redirect_uri': 'http://localhost:8040/youtube'} 32 | 33 | soundcloud = { 34 | 'key': 'key', 35 | 'api_key': 'api_key', 36 | 'secret': 'secret', 37 | 'redirect_uri': 'http://localhost:8040/soundcloud'} 38 | 39 | cloudplayer = { 40 | 'key': 'key', 41 | 'api_key': 'api_key', 42 | 'secret': 'secret', 43 | 'redirect_uri': 'http://localhost:8040/cloudplayer'} 44 | 45 | bugsnag = { 46 | 'api_key': 'api_key', 47 | 'project_root': '/usr/local/cloudplayer' 48 | } 49 | 50 | try: 51 | from private import * 52 | except ImportError: 53 | pass 54 | -------------------------------------------------------------------------------- /src/cloudplayer/api/controller/playlist.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.controller.playlist 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from sqlalchemy.sql.expression import func 9 | 10 | from cloudplayer.api.access import Available 11 | from cloudplayer.api.controller import Controller 12 | from cloudplayer.api.model.playlist import Playlist 13 | 14 | 15 | class PlaylistController(Controller): 16 | 17 | __model__ = Playlist 18 | 19 | async def read(self, ids, fields=Available): 20 | if ids['id'] == 'random': 21 | provider_id = ids['provider_id'] 22 | account = self.get_account(provider_id) 23 | kw = dict( 24 | account_id=account.id, 25 | account_provider_id=account.provider_id, 26 | provider_id=provider_id) 27 | self.policy.grant_query(account, self.__model__, kw) 28 | ids = dict( 29 | provider_id=provider_id) 30 | query = await self.query(ids, kw) 31 | random = query.order_by(func.random()).first() 32 | if random: 33 | ids['id'] = random.id 34 | return await super().read(ids, fields=fields) 35 | -------------------------------------------------------------------------------- /source/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # Cloud-Player API documentation build configuration file 5 | # 6 | 7 | import os.path as path 8 | import sys 9 | import mock 10 | import setuptools 11 | from recommonmark.parser import CommonMarkParser 12 | 13 | sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) 14 | with mock.patch.object(setuptools, 'setup') as mock_setup: 15 | import setup as _setup # NOQA 16 | package_info = mock_setup.call_args[1] 17 | 18 | 19 | extensions = [ 20 | 'sphinx.ext.autodoc', 21 | 'sphinx.ext.todo', 22 | 'sphinx.ext.viewcode'] 23 | 24 | source_parsers = {'.md': CommonMarkParser} 25 | source_suffix = ['.rst', '.md'] 26 | master_doc = 'index' 27 | 28 | project = package_info['name'] 29 | copyright = package_info['license'] 30 | author = package_info['author'] 31 | version = package_info['version'] 32 | release = version.replace('.dev0', '') 33 | 34 | exclude_patterns = [] 35 | pygments_style = 'sphinx' 36 | todo_include_todos = True 37 | htmlhelp_basename = 'Cloud-PlayerAPIdoc' 38 | html_theme = 'alabaster' 39 | html_theme_options = { 40 | 'show_related': True 41 | } 42 | html_static_path = ['../static'] 43 | html_sidebars = { 44 | '**': [ 45 | 'relations.html', 46 | 'searchbox.html', 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/favourite_item.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.favourite_item 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.favourite_item import ( 9 | CloudplayerFavouriteItemController, 10 | SoundcloudFavouriteItemController, 11 | YoutubeFavouriteItemController) 12 | from cloudplayer.api.handler import CollectionMixin, EntityMixin 13 | from cloudplayer.api.http import HTTPHandler 14 | 15 | 16 | class CloudplayerEntity(EntityMixin, HTTPHandler): 17 | 18 | __controller__ = CloudplayerFavouriteItemController 19 | 20 | SUPPORTED_METHODS = ('DELETE', 'OPTIONS') 21 | 22 | 23 | class CloudplayerCollection(CollectionMixin, HTTPHandler): 24 | 25 | __controller__ = CloudplayerFavouriteItemController 26 | 27 | SUPPORTED_METHODS = ('GET', 'POST', 'OPTIONS') 28 | 29 | 30 | class SoundcloudCollection(CollectionMixin, HTTPHandler): 31 | 32 | __controller__ = SoundcloudFavouriteItemController 33 | 34 | SUPPORTED_METHODS = ('GET', 'OPTIONS') 35 | 36 | 37 | class YoutubeCollection(CollectionMixin, HTTPHandler): 38 | 39 | __controller__ = YoutubeFavouriteItemController 40 | 41 | SUPPORTED_METHODS = ('GET', 'OPTIONS') 42 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/user.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.user 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import sqlalchemy as sql 9 | import sqlalchemy.orm as orm 10 | 11 | from cloudplayer.api.access import Allow, Child, Fields, Read 12 | from cloudplayer.api.model import Base 13 | 14 | 15 | class User(Base): 16 | 17 | __acl__ = ( 18 | Allow(Child, Read, Fields( 19 | 'id', 20 | 'provider_id', 21 | 'accounts.id', 22 | 'accounts.provider_id', 23 | 'accounts.connected', 24 | 'accounts.favourite_id', 25 | 'accounts.image.id', 26 | 'accounts.image.small', 27 | 'accounts.image.medium', 28 | 'accounts.image.large', 29 | 'accounts.title', 30 | 'created', 31 | 'updated' 32 | )), 33 | ) 34 | __table_args__ = ( 35 | sql.PrimaryKeyConstraint( 36 | 'id'), 37 | ) 38 | 39 | id = sql.Column(sql.Integer) 40 | provider_id = 'cloudplayer' 41 | 42 | accounts = orm.relation( 43 | 'Account', 44 | back_populates='user', 45 | uselist=True, 46 | single_parent=True, 47 | cascade='all, delete-orphan') 48 | children = orm.synonym('accounts') 49 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/http/test_account.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.gen_test 5 | def test_account_entity_can_be_read_by_owner(user_fetch, account): 6 | response = yield user_fetch('/account/{}/me'.format(account.provider_id)) 7 | assert response.json()['id'] == account.id 8 | 9 | 10 | @pytest.mark.gen_test 11 | def test_account_returns_all_fields_for_owner(user_fetch, account): 12 | response = yield user_fetch('/account/{}/{}'.format( 13 | account.provider_id, account.id)) 14 | result = response.json() 15 | assert set(result.pop('image').keys()) == { 16 | 'id', 17 | 'small', 18 | 'medium', 19 | 'large'} 20 | assert set(result.keys()) == { 21 | 'id', 22 | 'provider_id', 23 | 'user_id', 24 | 'connected', 25 | 'favourite_id', 26 | 'title', 27 | 'created', 28 | 'updated'} 29 | 30 | 31 | @pytest.mark.gen_test 32 | def test_account_returns_limited_fields_for_others(user_fetch, other): 33 | response = yield user_fetch('/account/{}/{}'.format( 34 | other.provider_id, other.id)) 35 | result = response.json() 36 | assert set(result.pop('image').keys()) == { 37 | 'id', 38 | 'small', 39 | 'medium', 40 | 'large'} 41 | assert set(result.keys()) == { 42 | 'id', 43 | 'provider_id', 44 | 'title'} 45 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/track.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.track 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.controller.track import SoundcloudTrackController 9 | from cloudplayer.api.controller.track import TrackController 10 | from cloudplayer.api.controller.track import YoutubeTrackController 11 | from cloudplayer.api.handler import CollectionMixin, EntityMixin 12 | from cloudplayer.api.http import HTTPHandler 13 | 14 | 15 | class Collection(CollectionMixin, HTTPHandler): 16 | 17 | __controller__ = TrackController 18 | 19 | SUPPORTED_METHODS = ('GET', 'OPTIONS') 20 | 21 | 22 | class SoundcloudEntity(EntityMixin, HTTPHandler): 23 | 24 | __controller__ = SoundcloudTrackController 25 | 26 | SUPPORTED_METHODS = ('GET', 'OPTIONS') 27 | 28 | 29 | class SoundcloudCollection(CollectionMixin, HTTPHandler): 30 | 31 | __controller__ = SoundcloudTrackController 32 | 33 | SUPPORTED_METHODS = ('GET', 'OPTIONS') 34 | 35 | 36 | class YoutubeEntity(EntityMixin, HTTPHandler): 37 | 38 | __controller__ = YoutubeTrackController 39 | 40 | SUPPORTED_METHODS = ('GET', 'OPTIONS') 41 | 42 | 43 | class YoutubeCollection(CollectionMixin, HTTPHandler): 44 | 45 | __controller__ = YoutubeTrackController 46 | 47 | SUPPORTED_METHODS = ('GET', 'OPTIONS') 48 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/tracklist.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.tracklist 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import functools 9 | 10 | import sqlalchemy as sql 11 | import sqlalchemy.orm as orm 12 | from sqlalchemy.ext.declarative import declared_attr 13 | 14 | import cloudplayer.api.util 15 | 16 | 17 | class TracklistMixin(object): 18 | 19 | @declared_attr 20 | def __table_args__(cls): 21 | return ( 22 | sql.PrimaryKeyConstraint( 23 | 'id', 'provider_id'), 24 | sql.ForeignKeyConstraint( 25 | ['provider_id'], 26 | ['provider.id']), 27 | sql.ForeignKeyConstraint( 28 | ['account_id', 'account_provider_id'], 29 | ['account.id', 'account.provider_id']) 30 | ) 31 | 32 | id = sql.Column(sql.String(96), default=functools.partial( 33 | cloudplayer.api.util.gen_token, 16)) 34 | 35 | provider_id = sql.Column(sql.String(16), default='cloudplayer') 36 | 37 | @declared_attr 38 | def provider(cls): 39 | return orm.relation( 40 | 'Provider', 41 | cascade=None, 42 | viewonly=True) 43 | 44 | account_provider_id = sql.Column(sql.String(16), nullable=False) 45 | account_id = sql.Column(sql.String(32), nullable=False) 46 | -------------------------------------------------------------------------------- /src/cloudplayer/api/controller/favourite_item.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.controller.favourite_item 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.access import Available 9 | from cloudplayer.api.controller import Controller 10 | from cloudplayer.api.controller.track import TrackController 11 | from cloudplayer.api.model.favourite_item import FavouriteItem 12 | 13 | 14 | class FavouriteItemController(Controller): 15 | 16 | __model__ = FavouriteItem 17 | 18 | 19 | class CloudplayerFavouriteItemController(Controller): 20 | 21 | __provider__ = 'cloudplayer' 22 | __model__ = FavouriteItem 23 | 24 | 25 | class SoundcloudFavouriteItemController(Controller): 26 | 27 | __provider__ = 'soundcloud' 28 | __model__ = FavouriteItem 29 | 30 | 31 | class YoutubeFavouriteItemController(Controller): 32 | 33 | __provider__ = 'youtube' 34 | __model__ = FavouriteItem 35 | 36 | async def search(self, ids, kw, fields=Available): 37 | track_controller = TrackController.for_provider( 38 | self.__provider__, self.db, self.current_user) 39 | 40 | track_ids = {'provider_id': self.__provider__, 'ids': []} 41 | track_kw = {'rating': 'like'} 42 | tracks = await track_controller.mread( 43 | track_ids, track_kw, fields=fields) 44 | return tracks 45 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/controller/test_playlist.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import sqlalchemy.orm.util 3 | 4 | from cloudplayer.api.controller.playlist import PlaylistController 5 | from cloudplayer.api.model.playlist import Playlist 6 | 7 | 8 | @pytest.mark.gen_test 9 | async def test_playlist_controller_should_resolve_random_id(db, current_user): 10 | playlist = Playlist( 11 | account_id=current_user['cloudplayer'], 12 | account_provider_id='cloudplayer', 13 | provider_id='cloudplayer', 14 | title='test') 15 | 16 | db.add(playlist) 17 | db.commit() 18 | playlist_id = playlist.id 19 | db.expunge(playlist) 20 | controller = PlaylistController(db, current_user) 21 | ids = {'id': 'random', 'provider_id': 'cloudplayer'} 22 | playlist = await controller.read(ids) 23 | assert playlist.id == playlist_id 24 | 25 | 26 | @pytest.mark.gen_test 27 | async def test_playlist_controller_should_create_entity_and_read_result( 28 | db, current_user, account, user): 29 | controller = PlaylistController(db, current_user) 30 | ids = {'provider_id': 'cloudplayer'} 31 | kw = {'title': 'foo', 'account_id': account.id, 32 | 'account_provider_id': account.provider_id} 33 | entity = await controller.create(ids, kw) 34 | assert isinstance(entity.id, str) 35 | assert entity.provider_id == 'cloudplayer' 36 | assert entity.title == 'foo' 37 | assert entity.account is account 38 | assert sqlalchemy.orm.util.object_state(entity).persistent 39 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/access/test_fields.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from cloudplayer.api.access import Available, Fields 4 | 5 | 6 | def test_fields_should_be_eager_with_values_and_lazy_with_targets(): 7 | fields = Fields('one', 'two', 'six') 8 | assert fields._target is None 9 | assert fields._values == {'one', 'two', 'six'} 10 | target = object() 11 | assert fields in fields(target) 12 | assert fields(target)._target is target 13 | assert set(fields) == {'one', 'two', 'six'} 14 | 15 | 16 | def test_fields_should_check_field_containment_against_values(): 17 | fields = Fields('one', 'two', 'six') 18 | assert 'one' in fields 19 | assert 'zero' not in fields 20 | f1 = Fields('one', 'five') 21 | assert f1 not in fields 22 | f2 = Fields('one', 'two', 'six', 'nine') 23 | assert f2 not in fields 24 | f3 = Fields('one', 'six') 25 | assert f3 in fields 26 | 27 | 28 | def test_available_fields_init_eagerly_and_keep_empty_values(): 29 | target = mock.Mock() 30 | f1 = Available(target) 31 | assert f1._target is target 32 | assert len(list(f1)) == 0 33 | f2 = Available(target)(target) 34 | assert f2._target is target 35 | assert len(list(f2)) == 0 36 | 37 | 38 | def test_available_fields_are_always_contained_in_specific_fields(): 39 | target = mock.Mock() 40 | f1 = Available(target) 41 | f2 = Fields('ten', 'six', 'two') 42 | assert f1 in f2 43 | f1 = Available(target) 44 | f2 = Fields() 45 | assert f1 in f2 46 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/token.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.token 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import functools 9 | 10 | import sqlalchemy as sql 11 | import sqlalchemy.orm as orm 12 | 13 | from cloudplayer.api.access import (Allow, Create, Everyone, Fields, Read, 14 | Update) 15 | from cloudplayer.api.model import Base 16 | from cloudplayer.api.util import gen_token 17 | 18 | 19 | class Token(Base): 20 | 21 | __acl__ = ( 22 | Allow(Everyone, Create, Fields()), 23 | Allow(Everyone, Read, Fields( 24 | 'id', 25 | 'claimed' 26 | )), 27 | Allow(Everyone, Update, Fields( 28 | 'claimed', 29 | 'account_id', 30 | 'account_provider_id' 31 | )) 32 | ) 33 | __table_args__ = ( 34 | sql.PrimaryKeyConstraint( 35 | 'id'), 36 | sql.ForeignKeyConstraint( 37 | ['account_id', 'account_provider_id'], 38 | ['account.id', 'account.provider_id']) 39 | ) 40 | 41 | id = sql.Column(sql.String(16), default=functools.partial(gen_token, 6)) 42 | 43 | claimed = sql.Column(sql.Boolean, default=False) 44 | 45 | account_provider_id = sql.Column(sql.String(16)) 46 | account_id = sql.Column(sql.String(32)) 47 | account = orm.relation( 48 | 'Account', 49 | single_parent=True) 50 | parent = orm.synonym('account') 51 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/test_routing.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | import re 3 | 4 | import pytest 5 | import tornado.web 6 | 7 | from cloudplayer.api.routing import ProtocolMatches 8 | 9 | 10 | def test_protocol_matcher_should_ensure_regex_termination(): 11 | matcher = ProtocolMatches('proto') 12 | assert matcher.protocol_pattern.pattern == 'proto$' 13 | 14 | 15 | def test_protocol_matcher_should_allow_regex_as_pattern(): 16 | matcher = ProtocolMatches(re.compile('^reg.*ex$')) 17 | assert matcher.protocol_pattern.pattern == '^reg.*ex$' 18 | 19 | 20 | @pytest.mark.gen_test 21 | async def test_protocol_matcher_should_route_requests_based_on_protocol(): 22 | 23 | async def app_one(*_): 24 | return 'ONE' 25 | 26 | async def app_two(*_): 27 | return 'TWO' 28 | 29 | async def catch_all(*_): 30 | return 'ALL' 31 | 32 | request = mock.Mock() 33 | routes = [ 34 | (ProtocolMatches('^one[s]?$'), app_one), 35 | (ProtocolMatches('two'), app_two), 36 | (ProtocolMatches('.*'), catch_all)] 37 | base_app = tornado.web.Application(routes) 38 | 39 | async def assert_route(proto, expected): 40 | request.protocol = proto 41 | delegate = base_app.find_handler(request) 42 | response = await delegate.request_callback(base_app, request) 43 | assert response == expected 44 | 45 | await assert_route('ones', 'ONE') 46 | await assert_route('one', 'ONE') 47 | await assert_route('two', 'TWO') 48 | await assert_route('three', 'ALL') 49 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | 4 | setup( 5 | name='cloudplayer.api', 6 | version='0.5.0.dev0', 7 | author='Nicolas Drebenstedt', 8 | author_email='hello@cloud-player.io', 9 | url='https://cloud-player.io', 10 | description='REST API for Cloud-Player', 11 | packages=find_packages('src'), 12 | package_dir={'': 'src'}, 13 | include_package_data=True, 14 | zip_safe=False, 15 | license='Apache-2.0', 16 | namespace_packages=['cloudplayer'], 17 | setup_requires=['setuptools_git'], 18 | install_requires=[ 19 | 'bugsnag', 20 | 'isodate', 21 | 'psycopg2-binary', 22 | 'pycurl', 23 | 'PyJWT', 24 | 'redis', 25 | 'setuptools', 26 | 'sqlalchemy', 27 | 'tornado' 28 | ], 29 | extras_require={ 30 | 'test': [ 31 | 'asynctest', 32 | 'codecov', 33 | 'pylint', 34 | 'pytest-cov', 35 | 'pytest-pep8', 36 | 'pytest-postgresql==1.3.3', 37 | 'pytest-redis', 38 | 'pytest-remove-stale-bytecode', 39 | 'pytest-timeout', 40 | 'pytest-tornado', 41 | 'pytest' 42 | ], 43 | 'doc': [ 44 | 'recommonmark', 45 | 'sphinx-autobuild', 46 | 'sphinx', 47 | ] 48 | }, 49 | entry_points={ 50 | 'console_scripts': [ 51 | 'api=cloudplayer.api.app:main', 52 | 'pytest=pytest:main [test]', 53 | 'test=pytest:main [test]' 54 | ] 55 | } 56 | ) 57 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/session.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.session 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import functools 9 | 10 | import sqlalchemy as sql 11 | import sqlalchemy.orm as orm 12 | 13 | from cloudplayer.api.access import Allow, Create, Deny, Fields, Owner, Read 14 | from cloudplayer.api.model import Base 15 | from cloudplayer.api.util import gen_token 16 | 17 | 18 | class Session(Base): 19 | 20 | __acl__ = ( 21 | Allow(Owner, Create, Fields( 22 | 'account_id', 23 | 'account_provider_id', 24 | 'system', 25 | 'browser', 26 | 'screen' 27 | )), 28 | Allow(Owner, Read, Fields()), 29 | Deny() 30 | ) 31 | __table_args__ = ( 32 | sql.PrimaryKeyConstraint( 33 | 'id'), 34 | sql.ForeignKeyConstraint( 35 | ['account_id', 'account_provider_id'], 36 | ['account.id', 'account.provider_id']) 37 | ) 38 | 39 | id = sql.Column(sql.String(64), default=functools.partial(gen_token, 64)) 40 | 41 | account_id = sql.Column(sql.String(32), nullable=False) 42 | account_provider_id = sql.Column(sql.String(16), nullable=False) 43 | account = orm.relation( 44 | 'Account', 45 | back_populates='sessions', 46 | uselist=False, 47 | single_parent=True) 48 | parent = orm.synonym('account') 49 | 50 | system = sql.Column(sql.String(64), nullable=False) 51 | browser = sql.Column(sql.String(64), nullable=False) 52 | screen = sql.Column(sql.String(32), nullable=False) 53 | -------------------------------------------------------------------------------- /cloudplayer.conf: -------------------------------------------------------------------------------- 1 | server_tokens off; 2 | 3 | upstream api { 4 | server 127.0.0.1:8040; 5 | } 6 | 7 | server { 8 | listen 80 default_server; 9 | listen [::]:80 default_server; 10 | server_name api.cloud-player.io; 11 | return 301 https://$host$request_uri; 12 | } 13 | 14 | server { 15 | listen 443 ssl http2; 16 | listen [::]:443 ssl http2; 17 | server_name api.cloud-player.io; 18 | 19 | keepalive_timeout 70; 20 | 21 | ssl_certificate /etc/nginx/ssl/api.cloud-player.io.crt; 22 | ssl_certificate_key /etc/nginx/ssl/api.cloud-player.io.key; 23 | 24 | ssl_session_cache shared:SSL:50m; 25 | ssl_session_timeout 1d; 26 | ssl_session_tickets off; 27 | 28 | ssl_dhparam /etc/nginx/ssl/dhparam.pem; 29 | 30 | ssl_prefer_server_ciphers on; 31 | 32 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 33 | 34 | access_log /var/log/nginx/api.access.log; 35 | error_log /var/log/nginx/api.error.log error; 36 | 37 | location / { 38 | proxy_pass http://api; 39 | proxy_pass_header Server; 40 | proxy_redirect off; 41 | proxy_set_header Host $http_host; 42 | proxy_set_header X-Real-IP $remote_addr; 43 | proxy_set_header X-Scheme $scheme; 44 | } 45 | 46 | location /websocket { 47 | proxy_pass http://api/websocket; 48 | proxy_pass_header Server; 49 | proxy_redirect off; 50 | proxy_http_version 1.1; 51 | proxy_set_header Connection "upgrade"; 52 | proxy_set_header Host $http_host; 53 | proxy_set_header Upgrade $http_upgrade; 54 | proxy_set_header X-Real-IP $remote_addr; 55 | proxy_set_header X-Scheme $scheme; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/cloudplayer/api/access/action.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.access.action 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | 9 | 10 | class Action(object): 11 | """Base class for access control list entities describing an action. 12 | 13 | Actions are intended by a principal and executed on a target. The target 14 | is bound to an action instance in its constructor. 15 | 16 | The equality operator is implemented to check whether the intent should 17 | be granted or not. 18 | """ 19 | 20 | def __init__(self, target=None): 21 | self._target = target 22 | 23 | def __eq__(self, other): 24 | raise NotImplementedError() # pragma: no cover 25 | 26 | 27 | class Anything(Action): 28 | """Wildcard action that describes any of the below.""" 29 | 30 | def __eq__(self, other): 31 | return True 32 | 33 | 34 | class Operation(Action): 35 | """Base class for specific CRUD operations.""" 36 | 37 | def __eq__(self, other): 38 | return type(self) is type(other) 39 | 40 | 41 | class Create(Operation): 42 | """Action describing a create operation.""" 43 | 44 | pass 45 | 46 | 47 | class Read(Operation): 48 | """Action describing a read operation.""" 49 | 50 | pass 51 | 52 | 53 | class Update(Operation): 54 | """Action describing a update operation.""" 55 | 56 | pass 57 | 58 | 59 | class Delete(Operation): 60 | """Action describing a delete operation.""" 61 | 62 | pass 63 | 64 | 65 | class Query(Operation): 66 | """Action describing a query operation.""" 67 | 68 | pass 69 | -------------------------------------------------------------------------------- /src/cloudplayer/api/controller/playlist_item.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.controller.playlist_item 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.access import Available 9 | from cloudplayer.api.controller import Controller, ControllerException 10 | from cloudplayer.api.controller.track import TrackController 11 | from cloudplayer.api.model.playlist import Playlist 12 | from cloudplayer.api.model.playlist_item import PlaylistItem 13 | 14 | 15 | class PlaylistItemController(Controller): 16 | 17 | __model__ = PlaylistItem 18 | 19 | async def create(self, ids, kw, fields=Available): 20 | track_id = kw.get('track_id') 21 | track_provider_id = kw.get('track_provider_id') 22 | track_controller = TrackController.for_provider( 23 | track_provider_id, self.db, self.current_user) 24 | track = await track_controller.read({ 25 | 'id': track_id, 'provider_id': track_provider_id}) 26 | if not track: 27 | raise ControllerException(404, 'track not found') 28 | 29 | playlist = self.db.query(Playlist).get( 30 | (ids.pop('playlist_id'), ids.pop('playlist_provider_id'))) 31 | if not playlist: 32 | raise ControllerException(404, 'playlist not found') 33 | 34 | if not playlist.image: 35 | playlist.image = track.image.copy() 36 | self.db.add(playlist) 37 | 38 | return await super().create(ids, kw, fields=fields) 39 | 40 | async def query(self, ids, kw): 41 | query = await super().query(ids, kw) 42 | return query.order_by(PlaylistItem.rank) 43 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/image.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.image 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import sqlalchemy as sql 9 | 10 | from cloudplayer.api.access import Allow, Deny, Everyone, Fields, Read 11 | from cloudplayer.api.model import Base 12 | 13 | 14 | class Image(Base): 15 | 16 | __acl__ = ( 17 | Allow(Everyone, Read, Fields( 18 | 'id', 19 | 'small', 20 | 'medium', 21 | 'large', 22 | 'created', 23 | 'updated' 24 | )), 25 | Deny() 26 | ) 27 | __table_args__ = ( 28 | sql.PrimaryKeyConstraint( 29 | 'id'), 30 | ) 31 | 32 | id = sql.Column(sql.Integer) 33 | small = sql.Column(sql.String(256)) 34 | medium = sql.Column(sql.String(256)) 35 | large = sql.Column(sql.String(256), nullable=False) 36 | 37 | def copy(self): 38 | return Image( 39 | small=self.small, 40 | medium=self.medium, 41 | large=self.large) 42 | 43 | @classmethod 44 | def from_soundcloud(cls, url): 45 | if isinstance(url, str): 46 | return cls( 47 | small=url, 48 | medium=url.replace('large', 't300x300'), 49 | large=url.replace('large', 't500x500')) 50 | 51 | @classmethod 52 | def from_youtube(cls, thumbnails): 53 | if isinstance(thumbnails, dict): 54 | return cls( 55 | small=thumbnails.get('default', {}).get('url'), 56 | medium=thumbnails.get('medium', {}).get('url'), 57 | large=thumbnails.get('high', {}).get('url')) 58 | -------------------------------------------------------------------------------- /spec/asyncapi.yml: -------------------------------------------------------------------------------- 1 | asyncapi: '1.0.0' 2 | info: 3 | title: Cloud-Player 4 | version: '1.0' 5 | description: cloud-player websocket channels 6 | servers: 7 | - url: api.cloud-player.io 8 | scheme: wss 9 | security: 10 | - httpApiKey: [] 11 | 12 | topics: 13 | user.{id}: 14 | publish: 15 | $ref: '#/components/messages/userEntityRead' 16 | subscribe: 17 | $ref: '#/components/messages/onUserUpdate' 18 | 19 | components: 20 | securitySchemes: 21 | httpApiKey: 22 | name: tok_v1 23 | type: httpApiKey 24 | description: '' 25 | in: cookie 26 | 27 | messages: 28 | userEntityRead: 29 | headers: 30 | type: object 31 | properties: 32 | channel: 33 | title: channel 34 | description: Channel descriptor 35 | type: string 36 | method: 37 | title: method 38 | description: Execution method 39 | default: PUT 40 | type: string 41 | enum: 42 | - GET 43 | - POST 44 | - PUT 45 | - DELETE 46 | payload: 47 | items: 48 | $ref: 'user.yml#/User' 49 | type: array 50 | onUserUpdate: 51 | headers: 52 | type: object 53 | properties: 54 | channel: 55 | title: channel 56 | description: Channel descriptor 57 | type: string 58 | method: 59 | title: method 60 | description: Execution method 61 | default: PUT 62 | type: string 63 | enum: 64 | - SUBSCRIBE 65 | - UNSUBSCRIBE 66 | payload: 67 | type: array 68 | items: 69 | $ref: 'user.yml#/User' 70 | -------------------------------------------------------------------------------- /src/cloudplayer/api/controller/token.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.controller.token 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import datetime 9 | 10 | from cloudplayer.api.access import Available 11 | from cloudplayer.api.controller import Controller, ControllerException 12 | from cloudplayer.api.model.token import Token 13 | 14 | 15 | class TokenController(Controller): 16 | 17 | __model__ = Token 18 | 19 | async def read(self, ids, fields=Available): 20 | threshold = datetime.datetime.utcnow() - datetime.timedelta(minutes=5) 21 | query = self.db.query( 22 | self.__model__).filter_by(**ids).filter(Token.created > threshold) 23 | entity = query.first() 24 | if not entity: 25 | raise ControllerException(404, 'token not found') 26 | account = self.get_account(entity.provider_id) 27 | self.policy.grant_read(account, entity, fields) 28 | if entity.claimed: 29 | self.current_user.clear() 30 | self.current_user['user_id'] = entity.account.user_id 31 | for a in entity.account.user.accounts: 32 | self.current_user[a.provider_id] = a.id 33 | entity.account_id = None 34 | entity.account_provider_id = None 35 | self.db.commit() 36 | return entity 37 | 38 | async def update(self, ids, kw, fields=Available): 39 | token = { 40 | 'id': ids['id'], 41 | 'claimed': True, 42 | 'account_id': self.current_user['cloudplayer'], 43 | 'account_provider_id': 'cloudplayer'} 44 | if kw != token: 45 | raise ControllerException(404, 'invalid update') 46 | return await super().update(ids, kw, fields=fields) 47 | -------------------------------------------------------------------------------- /src/cloudplayer/api/access/fields.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.access.fields 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | 9 | 10 | class Fields(object): 11 | """Component for fine-tuning column-level ACL rules. 12 | 13 | Fields contain a set of column names that may restrict a CRUD operation. 14 | E.g., the owner of an entity may read more attributes than a third party. 15 | 16 | Column names are provided as positional arguments and can have a dotted 17 | syntax to describe columns of related models. 18 | The constructer accepts an additional target keyword to provide 19 | compatibility with the `Available` fields class. 20 | 21 | Calling a fields instance with a target entity creates a bound copy. 22 | 23 | The `in` operator is implemented to check whether the intent applies 24 | to the specified field restriction. 25 | """ 26 | 27 | def __init__(self, *args, target=None): 28 | self._values = frozenset(args) 29 | self._target = target 30 | 31 | def __call__(self, target): 32 | return Fields(*self._values, target=target) 33 | 34 | def __iter__(self): 35 | yield from self._values 36 | 37 | def __contains__(self, item): 38 | if isinstance(item, Fields): 39 | return self._values.issuperset(item._values) 40 | return self._values.__contains__(item) 41 | 42 | 43 | class Available(Fields): 44 | """Wildcard fields class that unlocks all fields in an ACL rule. 45 | 46 | Rules must reference the class uninstanciated and the target is bound 47 | through the constructor. 48 | """ 49 | 50 | def __init__(self, target): 51 | self(target) 52 | 53 | def __call__(self, target): 54 | self._target = target 55 | self._values = frozenset() 56 | return self 57 | 58 | def __contains__(self, item): 59 | return True 60 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/http/test_playlist.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from cloudplayer.api.model.playlist import Playlist 4 | from cloudplayer.api.model.playlist_item import PlaylistItem 5 | 6 | 7 | @pytest.mark.gen_test 8 | async def test_playlist_can_be_created(user_fetch, account): 9 | body = {'title': 'test playlist'} 10 | response = await user_fetch( 11 | '/playlist/cloudplayer', method='POST', body=body) 12 | result = response.json() 13 | assert result['account_id'] == account.id 14 | assert result['id'] is not None 15 | assert result['title'] == 'test playlist' 16 | assert result['description'] is None 17 | 18 | 19 | @pytest.mark.gen_test 20 | async def test_playlist_can_be_deleted_without_tracks(user_fetch): 21 | body = {'title': 'test playlist'} 22 | response = await user_fetch( 23 | '/playlist/cloudplayer', method='POST', body=body) 24 | response = await user_fetch('/playlist/cloudplayer/{}'.format( 25 | response.json()['id']), method='DELETE') 26 | assert response.code == 204 27 | 28 | 29 | @pytest.mark.gen_test 30 | async def test_playlist_can_be_deleted_with_cascading_tracks( 31 | db, user_fetch, account): 32 | item = PlaylistItem( 33 | account=account, 34 | rank='aaa', 35 | track_id='test-id', 36 | track_provider_id='cloudplayer') 37 | playlist = Playlist( 38 | title='test playlist', 39 | provider_id='cloudplayer', 40 | items=[item], 41 | account_id=account.id, 42 | account_provider_id=account.provider_id) 43 | db.add(playlist) 44 | db.commit() 45 | item_ids = (item.id,) 46 | playlist_ids = (playlist.id, playlist.provider_id) 47 | 48 | response = await user_fetch( 49 | '/playlist/cloudplayer/{}'.format(playlist.id), method='DELETE') 50 | assert response.code == 204 51 | 52 | db.expunge_all() 53 | assert not db.query(Playlist).get(playlist_ids) 54 | assert not db.query(PlaylistItem).get(item_ids) 55 | -------------------------------------------------------------------------------- /src/cloudplayer/api/controller/track_comment.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.controller.track_comment 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import datetime 9 | 10 | import tornado.escape 11 | 12 | from cloudplayer.api.access import Available 13 | from cloudplayer.api.controller import Controller 14 | from cloudplayer.api.model.account import Account 15 | from cloudplayer.api.model.image import Image 16 | from cloudplayer.api.model.track_comment import TrackComment 17 | 18 | 19 | class SoundcloudTrackCommentController(Controller): 20 | 21 | DATE_FORMAT = '%Y/%m/%d %H:%M:%S %z' 22 | 23 | async def search(self, ids, kw, fields=Available): 24 | response = await self.fetch( 25 | ids['track_provider_id'], 26 | '/tracks/{}/comments'.format(ids['track_id'])) 27 | comments = tornado.escape.json_decode(response.body) 28 | 29 | entities = [] 30 | for comment in comments: 31 | user = comment['user'] 32 | writer = Account( 33 | id=user['id'], 34 | provider_id=ids['track_provider_id'], 35 | title=user['username'], 36 | image=Image.from_soundcloud(user.get('avatar_url'))) 37 | entity = TrackComment( 38 | id=comment['id'], 39 | provider_id=ids['track_provider_id'], 40 | body=comment['body'], 41 | timestamp=comment['timestamp'], 42 | track_id=ids['track_id'], 43 | track_provider_id=ids['track_provider_id'], 44 | account=writer, 45 | created=datetime.datetime.strptime( 46 | comment['created_at'], self.DATE_FORMAT)) 47 | entities.append(entity) 48 | 49 | account = self.get_account(entity.provider_id) 50 | self.policy.grant_read(account, entities, fields) 51 | return entities 52 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/proxy.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.proxy 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import tornado.web 9 | 10 | from cloudplayer.api.controller import Controller 11 | from cloudplayer.api.http import HTTPHandler 12 | 13 | 14 | class Proxy(HTTPHandler): # pragma: no cover 15 | 16 | SUPPORTED_METHODS = ('GET', 'POST', 'PUT', 'DELETE', 'OPTIONS') 17 | 18 | async def proxy(self, method, provider, path, **kw): 19 | controller = Controller(self.db, self.current_user) 20 | response = await controller.fetch( 21 | provider, path, method=method, params=self.query_params, 22 | raise_error=False, **kw) 23 | body = response.body 24 | if response.error: 25 | code = response.error.code 26 | if code == 599: 27 | code = 503 28 | if not body: 29 | body = response.error.message 30 | self.set_status(code) 31 | else: 32 | body = body.replace( # Get your TLS on, YouTube! 33 | b'http://s.ytimg.com', b'https://s.ytimg.com') 34 | self.write_str(body) 35 | 36 | def write_str(self, data): 37 | tornado.web.RequestHandler.write(self, data) 38 | self.finish() 39 | 40 | async def get(self, provider, path): 41 | await self.proxy('GET', provider, path) 42 | 43 | async def post(self, provider, path): 44 | await self.proxy( 45 | 'POST', provider, path, 46 | body=self.request.body, 47 | headers={'Content-Type': 'application/json'}) 48 | 49 | async def put(self, provider, path): 50 | await self.proxy( 51 | 'PUT', provider, path, 52 | body=self.request.body, 53 | headers={'Content-Type': 'application/json'}) 54 | 55 | async def delete(self, provider, path): 56 | await self.proxy('DELETE', provider, path) 57 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/favourite_item.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.favourite_item 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import sqlalchemy as sql 9 | import sqlalchemy.orm as orm 10 | 11 | from cloudplayer.api.access import (Allow, Create, Delete, Fields, Owner, 12 | Parent, Query, Read) 13 | from cloudplayer.api.model import Base 14 | from cloudplayer.api.model.tracklist_item import TracklistItemMixin 15 | 16 | 17 | class FavouriteItem(TracklistItemMixin, Base): 18 | 19 | __acl__ = ( 20 | Allow(Parent, Create, Fields( 21 | 'favourite_id', 22 | 'favourite_provider_id', 23 | 'account_id', 24 | 'account_provider_id', 25 | 'track_id', 26 | 'track_provider_id' 27 | )), 28 | Allow(Owner, Read, Fields( 29 | 'id', 30 | 'track_id', 31 | 'track_provider_id' 32 | )), 33 | Allow(Owner, Delete), 34 | Allow(Parent, Query, Fields( 35 | 'favourite_id', 36 | 'favourite_provider_id' 37 | )) 38 | ) 39 | __table_args__ = ( 40 | sql.PrimaryKeyConstraint( 41 | 'id'), 42 | sql.ForeignKeyConstraint( 43 | ['favourite_provider_id', 'favourite_id'], 44 | ['favourite.provider_id', 'favourite.id']), 45 | sql.ForeignKeyConstraint( 46 | ['account_id', 'account_provider_id'], 47 | ['account.id', 'account.provider_id']), 48 | sql.ForeignKeyConstraint( 49 | ['track_provider_id'], 50 | ['provider.id']) 51 | ) 52 | 53 | provider_id = orm.synonym('favourite_provider_id') 54 | 55 | favourite_id = sql.Column(sql.String(96), nullable=False) 56 | favourite_provider_id = sql.Column(sql.String(16), nullable=False) 57 | favourite = orm.relation( 58 | 'Favourite', 59 | back_populates='items') 60 | parent = orm.synonym('favourite') 61 | -------------------------------------------------------------------------------- /static/close.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Cloud-Player 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 48 | 49 | 50 |
51 |
52 |

You can close this tab now.

53 |
54 |
55 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/socket.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.socket 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import json 9 | 10 | import tornado.ioloop 11 | from tornado.websocket import WebSocketHandler 12 | 13 | from cloudplayer.api.http import HTTPHandler 14 | from cloudplayer.api.ws import WSRequest 15 | 16 | 17 | class Handler(HTTPHandler, WebSocketHandler): 18 | 19 | def __init__(self, application, request): 20 | WebSocketHandler.__init__(self, application, request) 21 | HTTPHandler.__init__(self, application, request) 22 | 23 | def open(self): 24 | self.listener = tornado.ioloop.PeriodicCallback(self.listen, 100) 25 | self.listener.start() 26 | 27 | async def on_message(self, message): 28 | try: 29 | message = json.loads(message) 30 | assert isinstance(message, dict) 31 | except (AssertionError, ValueError): 32 | self.close(code=1003, reason='invalid json') 33 | return 34 | request = WSRequest( 35 | self.ws_connection, 36 | self.pubsub, 37 | self.current_user, 38 | self.request, 39 | message) 40 | delegate = self.application.find_handler(request) 41 | handler = delegate.request_callback(self.application, request) 42 | try: 43 | await handler() 44 | except Exception as exception: 45 | handler._handle_request_exception(exception) 46 | else: 47 | self.application.log_request(handler) 48 | finally: 49 | request.finish() 50 | handler.on_finish() 51 | 52 | def listen(self): 53 | if self.pubsub.subscribed: 54 | self.pubsub.get_message(ignore_subscribe_messages=True) 55 | else: 56 | self.pubsub.close() 57 | if self.listener.is_running(): 58 | self.listener.stop() 59 | 60 | def on_close(self): 61 | if self.pubsub: 62 | self.pubsub.unsubscribe() 63 | 64 | def check_origin(self, origin): 65 | return origin in self.settings['allowed_origins'] 66 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/test_app.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | import pytest 3 | import tornado.httpclient 4 | 5 | import cloudplayer.api.app 6 | import cloudplayer.api.http.base 7 | import cloudplayer.api.ws.base 8 | 9 | 10 | @pytest.mark.gen_test 11 | def test_base_route_should_return_404(http_client, base_url): 12 | response = yield http_client.fetch(base_url, raise_error=False) 13 | assert response.code == 404 14 | 15 | 16 | @pytest.mark.gen_test 17 | def test_unsupported_method_should_return_405(http_client, base_url): 18 | response = yield http_client.fetch( 19 | base_url, method='HEAD', raise_error=False) 20 | assert response.code == 405 21 | 22 | 23 | def test_application_should_define_protocol_based_routing(app): 24 | request = tornado.httputil.HTTPServerRequest(uri='/') 25 | request.protocol = 'http' 26 | assert app.find_handler(request).handler_class is ( 27 | cloudplayer.api.http.base.HTTPFallback) 28 | request.protocol = 'wss' 29 | assert app.find_handler(request).request_callback.func is ( 30 | cloudplayer.api.ws.base.WSFallback) 31 | 32 | 33 | def test_application_should_open_configured_redis_pool(app): 34 | assert app.redis_pool.connection_class.description_format % ( 35 | app.redis_pool.connection_kwargs) == ( 36 | 'Connection') 37 | 38 | 39 | def test_application_should_connect_to_configured_database(app): 40 | assert str(app.database.engine.url) == ( 41 | 'postgresql://postgres:@127.0.0.1:8852/postgres') 42 | 43 | 44 | def test_database_should_create_sessions_bound_to_engine(app): 45 | session = app.database.create_session() 46 | assert session.get_bind() is app.database.engine 47 | 48 | 49 | def test_application_shuts_down_database_events_and_redis( 50 | monkeypatch, app): 51 | event_list = list() 52 | dispose_pool = mock.MagicMock() 53 | disconnect_redis = mock.MagicMock() 54 | 55 | monkeypatch.setattr(app.event_mapper, 'listeners', event_list) 56 | monkeypatch.setattr(app.database.engine.pool, 'dispose', dispose_pool) 57 | monkeypatch.setattr(app.redis_pool, 'disconnect', disconnect_redis) 58 | 59 | app.shutdown() 60 | 61 | assert not app.event_mapper.listeners 62 | dispose_pool.assert_called_once() 63 | disconnect_redis.assert_called_once() 64 | -------------------------------------------------------------------------------- /src/cloudplayer/api/access/rule.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.access.rule 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api.access.action import Anything 9 | from cloudplayer.api.access.fields import Available 10 | from cloudplayer.api.access.policy import PolicyViolation 11 | from cloudplayer.api.access.principal import Everyone, Principal 12 | 13 | 14 | class Grant(object): 15 | """Bundles metadata on a grant issued for an ACL intent.""" 16 | 17 | def __init__(self, principal=None, action=None, target=None, fields=None): 18 | self.principal = principal 19 | self.action = action 20 | self.target = target 21 | self.fields = fields 22 | 23 | 24 | class Rule(object): 25 | """Main ACL entry declaring a certain action allowed or denied.""" 26 | 27 | def __init__(self, principal=Everyone, action=Anything, fields=Available): 28 | self.principal = principal 29 | self.action = action 30 | self.fields = fields 31 | 32 | 33 | class Allow(Rule): 34 | """Rule that allows an actor to act on a set of target fields.""" 35 | 36 | def __call__(self, account, action, target, fields): 37 | grant = Grant(account, action, target, fields) 38 | 39 | proposed_principal = Principal(account) 40 | required_principal = self.principal(target) 41 | if required_principal != proposed_principal: 42 | return 43 | 44 | proposed_action = action(target) 45 | required_action = self.action(target) 46 | if required_action != proposed_action: 47 | return 48 | 49 | proposed_fields = fields(target) 50 | required_fields = self.fields(target) 51 | if fields is Available: 52 | grant.fields = required_fields 53 | if proposed_fields in required_fields: 54 | return grant 55 | 56 | 57 | class Deny(Rule): 58 | """Rule that denies an actor to act on a set of target fields.""" 59 | 60 | def __call__(self, account, action, target, fields): 61 | if self.principal(target) == Principal(account): 62 | if self.action(target) == action(target): 63 | if fields(target) in self.fields(target): 64 | raise PolicyViolation(403, 'operation forbidden') 65 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/access/test_rule.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | import itertools 3 | 4 | import pytest 5 | 6 | from cloudplayer.api.access.action import Anything 7 | from cloudplayer.api.access.fields import Available, Fields 8 | from cloudplayer.api.access.policy import PolicyViolation 9 | from cloudplayer.api.access.principal import Everyone 10 | from cloudplayer.api.access.rule import Allow, Deny, Rule 11 | 12 | 13 | def test_rule_should_bind_to_principal_action_fields_with_defaults(): 14 | assert Rule().principal == Everyone 15 | assert Rule(principal='p').principal == 'p' 16 | assert Rule().action == Anything 17 | assert Rule(action='a').action == 'a' 18 | assert Rule().fields == Available 19 | assert Rule(fields='f').fields == 'f' 20 | 21 | 22 | class ArgMocker(object): 23 | 24 | def __init__(self, arg): 25 | super().__init__() 26 | self.arg = arg 27 | 28 | def __call__(self, *_): 29 | return self 30 | 31 | def __eq__(self, *_): 32 | return self.arg 33 | 34 | def __contains__(self, *_): 35 | return self.arg 36 | 37 | 38 | def test_allow_should_return_grant_if_rule_matches_else_none(): 39 | yay = ArgMocker(True) 40 | nay = ArgMocker(False) 41 | rule = Allow(yay, yay, yay) 42 | account = mock.Mock() 43 | action = mock.MagicMock() 44 | target = mock.MagicMock() 45 | fields = Fields('one', 'four', 'eight') 46 | grant = rule(account, action, target, fields) 47 | assert grant.principal is account 48 | assert grant.action is action 49 | assert grant.target is target 50 | assert grant.fields is fields 51 | 52 | arg_list = list(itertools.product([yay, nay], repeat=3)) 53 | arg_list.remove((yay, yay, yay)) 54 | for args in arg_list: 55 | rule = Allow(*args) 56 | assert not rule( 57 | mock.Mock(), mock.MagicMock(), mock.MagicMock, mock.MagicMock()) 58 | 59 | 60 | def test_deny_should_raise_violation_if_matches_else_return_none(): 61 | yay = ArgMocker(True) 62 | nay = ArgMocker(False) 63 | rule = Deny(yay, yay, yay) 64 | with pytest.raises(PolicyViolation): 65 | assert rule(mock.Mock(), mock.MagicMock(), None, mock.MagicMock()) 66 | arg_list = list(itertools.product([yay, nay], repeat=3)) 67 | arg_list.remove((yay, yay, yay)) 68 | for args in arg_list: 69 | rule = Deny(*args) 70 | assert not rule(mock.Mock(), mock.MagicMock(), None, mock.MagicMock()) 71 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/expected/tracks/youtube.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "provider_id": "youtube", 4 | "account": { 5 | "provider_id": "youtube", 6 | "title": "jasoneric", 7 | "id": "UCJFwqPzrd3p2qu7-L6WZuBQ", 8 | "image": null 9 | }, 10 | "image": { 11 | "medium": "https://i.ytimg.com/vi/PDZcqBgCS74/mqdefault.jpg", 12 | "small": "https://i.ytimg.com/vi/PDZcqBgCS74/default.jpg", 13 | "large": "https://i.ytimg.com/vi/PDZcqBgCS74/hqdefault.jpg" 14 | }, 15 | "title": "Lionel Richie - Hello", 16 | "aspect_ratio": 0.75, 17 | "play_count": "68567907", 18 | "favourite_count": "214569", 19 | "id": "PDZcqBgCS74", 20 | "created": "2006-03-18T22:40:09", 21 | "duration": 332 22 | }, 23 | { 24 | "provider_id": "youtube", 25 | "account": { 26 | "provider_id": "youtube", 27 | "title": "Kontor.TV", 28 | "id": "UCb3tJ5NKw7mDxyaQ73mwbRg", 29 | "image": null 30 | }, 31 | "image": { 32 | "medium": "https://i.ytimg.com/vi/kK42LZqO0wA/mqdefault.jpg", 33 | "small": "https://i.ytimg.com/vi/kK42LZqO0wA/default.jpg", 34 | "large": "https://i.ytimg.com/vi/kK42LZqO0wA/hqdefault.jpg" 35 | }, 36 | "title": "Martin Solveig & Dragonette - Hello (Official Short Video Version HD)", 37 | "aspect_ratio": 0.5625, 38 | "play_count": "75918775", 39 | "favourite_count": "309720", 40 | "id": "kK42LZqO0wA", 41 | "created": "2010-10-01T09:08:44", 42 | "duration": 253 43 | }, 44 | { 45 | "provider_id": "youtube", 46 | "account": { 47 | "provider_id": "youtube", 48 | "title": "Giọng Hát Việt / The Voice Vietnam", 49 | "id": "UChi7ZkzGovVqYNEbetu6MQA", 50 | "image": null 51 | }, 52 | "image": { 53 | "medium": "https://i.ytimg.com/vi/yL6odWvurCI/mqdefault.jpg", 54 | "small": "https://i.ytimg.com/vi/yL6odWvurCI/default.jpg", 55 | "large": "https://i.ytimg.com/vi/yL6odWvurCI/hqdefault.jpg" 56 | }, 57 | "title": "Huỳnh Thanh Thảo - Hello | Tập 4 Vòng Giấu Mặt | The Voice - Giọng Hát Việt 2018", 58 | "aspect_ratio": 0.5625, 59 | "play_count": "1444595", 60 | "favourite_count": "8678", 61 | "id": "yL6odWvurCI", 62 | "created": "2018-06-10T15:00:03", 63 | "duration": 679 64 | } 65 | ] 66 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/access/test_principal.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from cloudplayer.api.model.account import Account 4 | from cloudplayer.api.access.principal import (Child, Everyone, Owner, Parent, 5 | Principal) 6 | 7 | 8 | def test_principal_should_bind_to_target(): 9 | target = mock.Mock() 10 | principal = Principal(target) 11 | assert principal._target is target 12 | 13 | 14 | def test_principal_should_compare_by_account_property(): 15 | a1 = mock.Mock() 16 | a2 = mock.Mock() 17 | a1.id = a2.id = 52 18 | a1.provider_id = a2.provider_id = 'foo' 19 | p1 = Principal(a1) 20 | p2 = Principal(a2) 21 | assert p1 == p2 22 | 23 | 24 | def test_principal_should_only_compare_to_principals(): 25 | assert Principal(mock.Mock()) != mock.Mock() 26 | 27 | 28 | def test_everyone_should_be_everyone(): 29 | target = mock.Mock() 30 | assert Everyone(target) == Principal(target) 31 | assert Everyone(target) == Everyone(target) 32 | assert Everyone(target) == Parent(target) 33 | 34 | 35 | def test_everyone_should_only_compare_to_principals(): 36 | assert Everyone(mock.Mock()) != mock.Mock() 37 | 38 | 39 | def test_everyone_should_not_expose_target_as_account(): 40 | account = mock.Mock() 41 | everyone = Everyone(account) 42 | assert everyone.account is not account 43 | assert everyone.account is None 44 | 45 | 46 | def test_ownership_should_be_inferred_by_account_property(): 47 | account = Account(id=55) 48 | target = mock.Mock() 49 | target.account = account 50 | assert Owner(target) == Principal(account) 51 | assert Owner(target) != Principal(Account(id=99)) 52 | 53 | 54 | def test_parenthood_should_be_inferred_by_parent_property(): 55 | account = Account(id=13) 56 | target = mock.Mock() 57 | target.parent.account = account 58 | assert Parent(target) == Principal(account) 59 | assert Parent(target) != Principal(Account(id=99)) 60 | 61 | 62 | def test_descendancy_should_be_inferred_by_children_property(): 63 | account = Account(id=13) 64 | target = mock.Mock() 65 | target.children = [account] 66 | assert Child(target) == Principal(account) 67 | assert Child(target) != Principal(Account(id=99)) 68 | 69 | 70 | def test_child_should_only_compare_to_principals(): 71 | assert Child(mock.Mock()) != mock.Mock() 72 | 73 | 74 | def test_child_should_not_expose_target_as_account(): 75 | account = mock.Mock() 76 | child = Child(account) 77 | assert child.account is not account 78 | assert child.account is None 79 | -------------------------------------------------------------------------------- /src/cloudplayer/api/access/principal.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.access.principal 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import traceback 9 | 10 | 11 | class Principal(object): 12 | """ACL component describing the actor of an intent or defined rule. 13 | 14 | A principal class reference is used in rules to narrow the audience 15 | to which the rules applies to. 16 | When verifing an intent, the current user is also wrapped in a principal 17 | instance to ensure comparability. 18 | 19 | The quality ooperator is implemented to check whether the intent applies 20 | to the specified principal definition. 21 | """ 22 | 23 | def __init__(self, target): 24 | self._target = target 25 | 26 | @property 27 | def account(self): 28 | return self._target 29 | 30 | def __eq__(self, other): 31 | if isinstance(other, Principal): 32 | try: 33 | return ( 34 | self.account is not None and 35 | other.account is not None and 36 | (self.account.id == other.account.id) is True and 37 | (self.account.provider_id == other.account.provider_id) 38 | is True) 39 | except Exception: 40 | traceback.print_exc() 41 | return False 42 | 43 | 44 | class Everyone(Principal): 45 | """Wildcard principal for use in rules that apply to everyone.""" 46 | 47 | @property 48 | def account(self): 49 | return 50 | 51 | def __eq__(self, other): 52 | if isinstance(other, Principal): 53 | return True 54 | return False 55 | 56 | 57 | class Owner(Principal): 58 | """Principal component that applies to the owner of its target.""" 59 | 60 | @property 61 | def account(self): 62 | return self._target.account 63 | 64 | 65 | class Parent(Principal): 66 | """Principal component applying to the owner of the target's parent.""" 67 | 68 | @property 69 | def account(self): 70 | return self._target.parent.account 71 | 72 | 73 | class Child(Principal): 74 | """Principal component that applies to the owner of a target's child. 75 | 76 | The only use case for the child principal are rules for the user model. 77 | """ 78 | 79 | @property 80 | def account(self): 81 | return 82 | 83 | def __eq__(self, other): 84 | if isinstance(other, Principal): 85 | return other.account in self._target.children 86 | return False 87 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/playlist_item.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.playlist_item 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import sqlalchemy as sql 9 | import sqlalchemy.orm as orm 10 | 11 | from cloudplayer.api.access import (Allow, Create, Delete, Deny, Fields, Owner, 12 | Parent, Query, Read, Update) 13 | from cloudplayer.api.model import Base 14 | from cloudplayer.api.model.tracklist_item import TracklistItemMixin 15 | 16 | 17 | class PlaylistItem(TracklistItemMixin, Base): 18 | 19 | __acl__ = ( 20 | Allow(Parent, Create, Fields( 21 | 'playlist', 22 | 'account_id', 23 | 'account_provider_id', 24 | 'rank', 25 | 'track_provider_id', 26 | 'track_id' 27 | )), 28 | Allow(Owner, Read, Fields( 29 | 'id', 30 | 'playlist_id', 31 | 'playlist_provider_id', 32 | 'rank', 33 | 'track_provider_id', 34 | 'track_id', 35 | 'created', 36 | 'updated' 37 | )), 38 | Allow(Owner, Update, Fields( 39 | 'rank' 40 | )), 41 | Allow(Owner, Delete), 42 | Allow(Parent, Query, Fields( 43 | 'playlist' 44 | )), 45 | Deny() 46 | ) 47 | __channel__ = ( 48 | 'playlist.{playlist_provider_id}.{playlist_id}.item', 49 | ) 50 | __fields__ = ( 51 | 'id', 52 | 'playlist_id', 53 | 'playlist_provider_id', 54 | 'rank', 55 | 'track_provider_id', 56 | 'track_id', 57 | 'created', 58 | 'updated' 59 | ) 60 | __table_args__ = ( 61 | sql.PrimaryKeyConstraint( 62 | 'id'), 63 | sql.ForeignKeyConstraint( 64 | ['playlist_provider_id', 'playlist_id'], 65 | ['playlist.provider_id', 'playlist.id']), 66 | sql.ForeignKeyConstraint( 67 | ['account_id', 'account_provider_id'], 68 | ['account.id', 'account.provider_id']), 69 | sql.ForeignKeyConstraint( 70 | ['track_provider_id'], 71 | ['provider.id']) 72 | ) 73 | 74 | provider_id = orm.synonym('playlist_provider_id') 75 | 76 | rank = sql.Column(sql.String(128), nullable=False) 77 | 78 | playlist_id = sql.Column(sql.String(96), nullable=False) 79 | playlist_provider_id = sql.Column(sql.String(16), nullable=False) 80 | playlist = orm.relation( 81 | 'Playlist', 82 | back_populates='items') 83 | parent = orm.synonym('playlist') 84 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/ws/test_account.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | import tornado.ioloop 5 | 6 | from cloudplayer.api.http.account import Entity 7 | 8 | 9 | @pytest.mark.gen_test 10 | async def test_account_entity_should_be_available_over_websocket( 11 | user_push, account): 12 | message = {'channel': 'account.cloudplayer.me'} 13 | response = await user_push(message) 14 | result = response.json() 15 | assert result['channel'] == 'account.cloudplayer.me' 16 | assert result['body']['id'] == account.id 17 | assert result['body']['provider_id'] == account.provider_id 18 | 19 | 20 | @pytest.mark.gen_test 21 | async def test_account_entity_should_be_subscribable_over_websocket( 22 | user_push, user_fetch, db, account, monkeypatch): 23 | monkeypatch.setattr(Entity, 'SUPPORTED_METHODS', ('GET', 'PATCH')) 24 | assert account.title != 'new title' 25 | 26 | message = {'channel': 'account.cloudplayer.{}'.format(account.id), 27 | 'method': 'SUB'} 28 | ws_resp = await user_push(message, keep_alive=True, await_reply=False) 29 | 30 | http_resp = await user_fetch( 31 | '/account/cloudplayer/{}'.format(account.id), 32 | method='PATCH', body={'title': 'new title'}) 33 | assert http_resp.code == 200 34 | 35 | ws_resp = await ws_resp.connection.read_message() 36 | message = json.loads(ws_resp) 37 | assert message['body']['title'] == 'new title' 38 | 39 | 40 | @pytest.mark.xfail(strict=True, raises=tornado.ioloop.TimeoutError) 41 | @pytest.mark.gen_test(timeout=3) 42 | async def test_account_entity_should_be_unsubscribable_over_websocket( 43 | user_push, user_fetch, db, account, monkeypatch): 44 | monkeypatch.setattr(Entity, 'SUPPORTED_METHODS', ('GET', 'PATCH')) 45 | 46 | message = {'channel': 'account.cloudplayer.{}'.format(account.id), 47 | 'method': 'SUB'} 48 | ws_resp = await user_push(message, keep_alive=True, await_reply=False) 49 | 50 | http_resp = await user_fetch( 51 | '/account/cloudplayer/{}'.format(account.id), 52 | method='PATCH', body={'title': 'new title'}) 53 | assert http_resp.code == 200 54 | 55 | ws_message = await ws_resp.connection.read_message() 56 | message = json.loads(ws_message) 57 | assert message['body']['title'] == 'new title' 58 | 59 | message = {'channel': 'account.cloudplayer.{}'.format(account.id), 60 | 'method': 'UNSUB'} 61 | await ws_resp.connection.write_message(json.dumps(message)) 62 | 63 | http_resp = await user_fetch( 64 | '/account/cloudplayer/{}'.format(account.id), 65 | method='PATCH', body={'title': 'other title'}) 66 | assert http_resp.code == 200 67 | 68 | await ws_resp.connection.read_message() 69 | -------------------------------------------------------------------------------- /src/cloudplayer/api/access/policy.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.access.policy 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | from cloudplayer.api import APIException 9 | from cloudplayer.api.access.action import Create, Delete, Query, Read, Update 10 | from cloudplayer.api.access.fields import Available, Fields 11 | 12 | 13 | class PolicyViolation(APIException): 14 | 15 | def __init__(self, status_code=403, log_message='operation forbidden'): 16 | super().__init__(status_code, log_message) 17 | 18 | 19 | class Policy(object): 20 | """Operator class that can verify the compliance of CRUD operations. 21 | 22 | An intent is passed as a method call and a grant is issued upon compliance 23 | or a violancion exception is raised on dissent. 24 | """ 25 | 26 | def __init__(self, db, current_user): 27 | self.db = db 28 | self.current_user = current_user 29 | 30 | @staticmethod 31 | def grant(account, action, target, fields): 32 | if fields is not Available and not isinstance(fields, Fields): 33 | fields = Fields(*fields) 34 | for rule in target.__acl__: 35 | grant = rule(account, action, target, fields) 36 | if grant: 37 | return grant 38 | raise PolicyViolation(404, 'no grant issued') 39 | 40 | def grant_create(self, account, entity, fields): 41 | return self.grant(account, Create, entity, fields) 42 | 43 | def grant_read(self, account, entity_or_entities, fields): 44 | # TODO: Find a better way to grant multi reads 45 | if isinstance(entity_or_entities, list): 46 | return self._grant_multi_read(account, entity_or_entities, fields) 47 | else: 48 | return self._grant_solo_read(account, entity_or_entities, fields) 49 | 50 | def _grant_solo_read(self, account, entity, fields): 51 | grant = self.grant(account, Read, entity, fields) 52 | entity.fields = grant.fields 53 | return grant 54 | 55 | def _grant_multi_read(self, account, entities, fields): 56 | grants = [] 57 | for entity in entities: 58 | grant = self._grant_solo_read(account, entity, fields) 59 | grants.append(grant) 60 | return grants 61 | 62 | def grant_update(self, account, entity, fields): 63 | return self.grant(account, Update, entity, fields) 64 | 65 | def grant_delete(self, account, entity): 66 | return self.grant(account, Delete, entity, Available) 67 | 68 | def grant_query(self, account, model, query): 69 | template = model(**query) 70 | self.db.enable_relationship_loading(template) 71 | grant = self.grant(account, Query, template, query.keys()) 72 | self.db.expunge(template) 73 | grant.target = model 74 | return grant 75 | -------------------------------------------------------------------------------- /source/fonts/slate.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Generated by IcoMoon 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/http/test_track_comment.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | import json 3 | 4 | import pytest 5 | 6 | from cloudplayer.api.controller import Controller 7 | 8 | 9 | @pytest.mark.gen_test 10 | async def test_track_comment_http_handler_should_convert_and_output_comments( 11 | user_fetch, monkeypatch): 12 | response = [{ 13 | 'id': 412581320, 14 | 'created_at': '2018/02/08 11:15:30 +0000', 15 | 'user_id': 1580910, 16 | 'track_id': 28907786, 17 | 'timestamp': 747293, 18 | 'body': 'The moment on 21:07 is the best!', 19 | 'user': { 20 | 'id': 1580910, 21 | 'permalink': 'foo-bar', 22 | 'username': 'foo bar', 23 | 'last_modified': '2015/11/19 07:27:30 +0000', 24 | 'avatar_url': 'https://host.img/02-large.jpg'} 25 | }, { 26 | 'id': 427684554, 27 | 'created_at': '2018/01/28 14:56:40 +0000', 28 | 'user_id': 1397300, 29 | 'track_id': 28907786, 30 | 'timestamp': 18562, 31 | 'body': 'love this bit!!!!', 32 | 'user': { 33 | 'id': 1397300, 34 | 'permalink': 'user-who-comments', 35 | 'username': 'user-who-comments', 36 | 'last_modified': '2016/11/28 19:22:00 +0000', 37 | 'avatar_url': 'https://host.img/92-large.jpg'} 38 | }] 39 | 40 | async def fetch(self, *args, **kw): 41 | return mock.Mock(body=json.dumps(response)) 42 | 43 | monkeypatch.setattr(Controller, 'fetch', fetch) 44 | 45 | response = await user_fetch('/track/soundcloud/28907786/comment') 46 | assert response.json() == [{ 47 | 'account': { 48 | 'id': '1580910', 49 | 'image': { 50 | 'large': 'https://host.img/02-t500x500.jpg', 51 | 'medium': 'https://host.img/02-t300x300.jpg', 52 | 'small': 'https://host.img/02-large.jpg'}, 53 | 'provider_id': 'soundcloud', 54 | 'title': 'foo bar'}, 55 | 'body': 'The moment on 21:07 is the best!', 56 | 'created': '2018-02-08T11:15:30+00:00', 57 | 'id': '412581320', 58 | 'provider_id': 'soundcloud', 59 | 'timestamp': 747293, 60 | 'track_id': '28907786', 61 | 'track_provider_id': 'soundcloud' 62 | }, { 63 | 'account': { 64 | 'id': '1397300', 65 | 'image': { 66 | 'large': 'https://host.img/92-t500x500.jpg', 67 | 'medium': 'https://host.img/92-t300x300.jpg', 68 | 'small': 'https://host.img/92-large.jpg'}, 69 | 'provider_id': 'soundcloud', 70 | 'title': 'user-who-comments'}, 71 | 'body': 'love this bit!!!!', 72 | 'created': '2018-01-28T14:56:40+00:00', 73 | 'id': '427684554', 74 | 'provider_id': 'soundcloud', 75 | 'timestamp': 18562, 76 | 'track_id': '28907786', 77 | 'track_provider_id': 'soundcloud'}] 78 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | sudo: false 3 | language: python 4 | cache: 5 | directories: 6 | - $HOME/.cache/pip 7 | - $HOME/.npm 8 | - $HOME/node_modules 9 | python: 10 | - "3.6" 11 | before_install: 12 | - wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add - 13 | - sudo apt-get update -q 14 | - sudo /etc/init.d/postgresql stop 15 | - sudo apt-get --purge -y remove postgresql 16 | install: 17 | - sudo apt-get install redis-server postgresql-10 -y 18 | - sudo sed -i 's/port = 5433/port = 5432/' /etc/postgresql/10/main/postgresql.conf 19 | - sudo /etc/init.d/postgresql restart 20 | - pip install --ignore-installed --editable .[test,doc] 21 | script: pytest 22 | before_cache: 23 | - rm -f $HOME/.cache/pip/log/debug.log 24 | after_success: 25 | - codecov 26 | - sudo apt-get install npm -y 27 | - make shins 28 | before_deploy: 29 | - zip -r latest * 30 | - mkdir -p aws_upload 31 | - mv latest.zip aws_upload/latest.zip 32 | deploy: 33 | - provider: pages 34 | edge: 35 | branch: v1.8.47 36 | skip_cleanup: true 37 | github_token: $GITHUB_TOKEN 38 | keep-history: true 39 | allow-empty-commit: true 40 | verbose: true 41 | local_dir: shins 42 | on: 43 | branch: production 44 | - provider: s3 45 | access_key_id: AKIAIQZ6VQOFSEMUSNYQ 46 | secret_access_key: 47 | secure: "Rw8YVfDcg1KWAjXntudY/swOZtmsApIpuJBQ2MzbyujkQAAeUgQL//iJ4IQ5qtzwc5IYQW/BFEo7N/m97FFeLloAQS+9IkxxEQDhxdWynlJiaSx1GolRSgfWUEPo06tbDviG/tghSlfw8WA/tazRqNdT71UyIdFwxxdaA2goQWYJaJaGN4xDee6GCwXiC4AnUsldBwmRADRyS9k4xEm/lMED3bvR9z8GZfytSoDfZex976u3uev3v/eEzfBpYuSnpSoLwYzwyj+wp6kuZaCJLtlgAsqqpGSqXEPSOxjErjAhK4Lawb8dB327jrkPuSxb1SFVscsH1Y+KkXEhYGB5QVCkHg6tfjPLpQboO0pePQ1zxqGO566r34D9UTPoWtsd6Z2cW6Fq8rncsKW+Jwxupsotq6xadwQJp2XkJBOKgfR4HbWYcRbyYh19kI9PN4Urp7STbvyYbX58+kmA7auVuiVyPrg0hLPVW+LVzV36Bl5EoBY7whNA0gMPp6yduKF81nV9QrfiaVnXkIwHhdEjTPGskpNeHGvqv2nWsQkU5HbID5O90rQbFUl+AqAW89hjg0SnlOJnW/JJT8lJiCIKPAnC9tTITRi2lhpsjB/AgGq9gJII5eyuKnThP69tBCm9e7SlXQ6byg2nf+F+JlD77uWyYk3QvXIGEWXa7gPDTpQ=" 48 | bucket: cloud-player-api 49 | region: eu-central-1 50 | local_dir: aws_upload 51 | skip_cleanup: true 52 | on: 53 | branch: production 54 | - provider: codedeploy 55 | access_key_id: AKIAIQZ6VQOFSEMUSNYQ 56 | secret_access_key: 57 | secure: "Rw8YVfDcg1KWAjXntudY/swOZtmsApIpuJBQ2MzbyujkQAAeUgQL//iJ4IQ5qtzwc5IYQW/BFEo7N/m97FFeLloAQS+9IkxxEQDhxdWynlJiaSx1GolRSgfWUEPo06tbDviG/tghSlfw8WA/tazRqNdT71UyIdFwxxdaA2goQWYJaJaGN4xDee6GCwXiC4AnUsldBwmRADRyS9k4xEm/lMED3bvR9z8GZfytSoDfZex976u3uev3v/eEzfBpYuSnpSoLwYzwyj+wp6kuZaCJLtlgAsqqpGSqXEPSOxjErjAhK4Lawb8dB327jrkPuSxb1SFVscsH1Y+KkXEhYGB5QVCkHg6tfjPLpQboO0pePQ1zxqGO566r34D9UTPoWtsd6Z2cW6Fq8rncsKW+Jwxupsotq6xadwQJp2XkJBOKgfR4HbWYcRbyYh19kI9PN4Urp7STbvyYbX58+kmA7auVuiVyPrg0hLPVW+LVzV36Bl5EoBY7whNA0gMPp6yduKF81nV9QrfiaVnXkIwHhdEjTPGskpNeHGvqv2nWsQkU5HbID5O90rQbFUl+AqAW89hjg0SnlOJnW/JJT8lJiCIKPAnC9tTITRi2lhpsjB/AgGq9gJII5eyuKnThP69tBCm9e7SlXQ6byg2nf+F+JlD77uWyYk3QvXIGEWXa7gPDTpQ=" 58 | deployment_group: DeployGroupCp 59 | region: eu-central-1 60 | bucket: cloud-player-api 61 | key: latest.zip 62 | bundle_type: zip 63 | application: ApiCp 64 | skip_cleanup: true 65 | on: 66 | branch: production 67 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/http/test_base.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | import http.cookies 3 | 4 | import pytest 5 | import tornado.web 6 | 7 | from cloudplayer.api.http.base import HTTPFallback, HTTPHandler, HTTPHealth 8 | 9 | 10 | def test_http_handler_supports_relevant_methods(app, req): 11 | methods = {'GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'} 12 | assert set(HTTPHandler.SUPPORTED_METHODS) == methods 13 | method_string = HTTPHandler(app, req).allowed_methods 14 | assert set(method_string.split(', ')) == methods 15 | 16 | 17 | def test_http_handler_stores_init_vars(app, req): 18 | handler = HTTPHandler(app, req) 19 | assert handler.application is app 20 | assert handler.request is req 21 | assert handler.current_user is None 22 | assert handler.original_user is None 23 | 24 | 25 | @pytest.mark.gen_test 26 | async def test_http_handler_should_set_default_headers(http_client, base_url): 27 | response = await http_client.fetch('{}/health_check'.format(base_url)) 28 | headers = dict(response.headers) 29 | headers.pop('X-Http-Reason', None) 30 | assert headers.pop('Date') 31 | assert headers.pop('Set-Cookie') 32 | assert headers.pop('Etag') 33 | assert headers.pop('Content-Length') 34 | assert headers == { 35 | 'Access-Control-Allow-Credentials': 'true', 36 | 'Access-Control-Allow-Headers': 'Accept, Content-Type, Origin', 37 | 'Access-Control-Allow-Methods': 'GET', 38 | 'Access-Control-Allow-Origin': '*', 39 | 'Access-Control-Max-Age': '600', 40 | 'Cache-Control': 'no-cache, no-store, must-revalidate', 41 | 'Content-Language': 'en-US', 42 | 'Content-Type': 'application/json', 43 | 'Pragma': 'no-cache', 44 | 'Server': 'cloudplayer'} 45 | 46 | 47 | @pytest.mark.gen_test 48 | async def test_http_handler_should_set_new_user_cookie(http_client, base_url): 49 | response = await http_client.fetch('{}/user/me'.format(base_url)) 50 | headers = dict(response.headers) 51 | cookie = http.cookies.SimpleCookie(headers.pop('Set-Cookie'))['tok_v1'] 52 | assert cookie['domain'] == 'localhost' 53 | assert cookie['httponly'] 54 | assert not cookie['secure'] 55 | assert cookie['expires'] 56 | 57 | 58 | @pytest.mark.gen_test 59 | async def test_http_fallback_throws_404_for_get_405_for_others(app, req): 60 | handler = HTTPFallback(app, req) 61 | with pytest.raises(tornado.web.HTTPError) as error: 62 | await handler.get() 63 | assert error.value.status_code == 404 64 | 65 | with pytest.raises(tornado.web.HTTPError) as error: 66 | await handler.post() 67 | assert error.value.status_code == 405 68 | 69 | 70 | @pytest.mark.gen_test 71 | async def test_http_health_should_query_redis_and_postgres( 72 | app, req, monkeypatch): 73 | handler = HTTPHealth(app, req) 74 | info = mock.MagicMock() 75 | monkeypatch.setattr(handler.cache, 'info', info) 76 | execute = mock.MagicMock() 77 | monkeypatch.setattr(handler.db, 'execute', execute) 78 | handler._transforms = [] 79 | await handler.get() 80 | info.assert_called_once_with('server') 81 | execute.assert_called_once_with('SELECT 1 = 1;') 82 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/playlist.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.playlist 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import sqlalchemy as sql 9 | import sqlalchemy.orm as orm 10 | from sqlalchemy.ext.declarative import declared_attr 11 | 12 | from cloudplayer.api.access import (Allow, Create, Delete, Deny, Fields, Owner, 13 | Query, Read, Update) 14 | from cloudplayer.api.model import Base 15 | from cloudplayer.api.model.tracklist import TracklistMixin 16 | 17 | 18 | class Playlist(TracklistMixin, Base): 19 | 20 | __acl__ = ( 21 | Allow(Owner, Create, Fields( 22 | 'provider_id', 23 | 'account_id', 24 | 'account_provider_id', 25 | 'description', 26 | 'public', 27 | 'title' 28 | )), 29 | Allow(Owner, Read, Fields( 30 | 'id', 31 | 'provider_id', 32 | 'account_id', 33 | 'account_provider_id', 34 | 'description', 35 | 'follower_count', 36 | 'image.id', 37 | 'image.small', 38 | 'image.medium', 39 | 'image.large', 40 | 'public', 41 | 'title', 42 | 'created', 43 | 'updated' 44 | )), 45 | Allow(Owner, Update, Fields( 46 | 'description', 47 | 'public', 48 | 'title' 49 | )), 50 | Allow(Owner, Delete), 51 | Allow(Owner, Query, Fields( 52 | 'id', 53 | 'provider_id', 54 | 'account_id', 55 | 'account_provider_id' 56 | )), 57 | Deny() 58 | ) 59 | __channel__ = ( 60 | 'playlist.{provider_id}.{id}', 61 | ) 62 | __fields__ = ( 63 | 'id', 64 | 'provider_id', 65 | 'account_id', 66 | 'account_provider_id', 67 | 'description', 68 | 'follower_count', 69 | 'image.id', 70 | 'image.small', 71 | 'image.medium', 72 | 'image.large', 73 | 'public', 74 | 'title', 75 | 'created', 76 | 'updated' 77 | ) 78 | 79 | @declared_attr 80 | def __table_args__(cls): 81 | return super().__table_args__ + ( 82 | sql.ForeignKeyConstraint( 83 | ['image_id'], 84 | ['image.id']), 85 | ) 86 | 87 | account = orm.relation( 88 | 'Account', 89 | back_populates='playlists', 90 | cascade='all', 91 | viewonly=True) 92 | parent = orm.synonym('account') 93 | 94 | items = orm.relation( 95 | 'PlaylistItem', 96 | cascade='all, delete-orphan', 97 | order_by='PlaylistItem.rank', 98 | single_parent=True) 99 | 100 | description = sql.Column(sql.Unicode(5120), nullable=True) 101 | follower_count = sql.Column(sql.Integer, default=0) 102 | public = sql.Column(sql.Boolean, default=False) 103 | title = sql.Column(sql.Unicode(256), nullable=False) 104 | 105 | image_id = sql.Column(sql.Integer) 106 | image = orm.relation( 107 | 'Image', 108 | cascade='all, delete-orphan', 109 | single_parent=True, 110 | uselist=False) 111 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/expected/tracks/soundcloud.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "9749542019247", 4 | "title": "Eu do qui qui do", 5 | "created": "2013-06-19T02:01:13+00:00", 6 | "play_count": 0, 7 | "account": { 8 | "provider_id": "soundcloud", 9 | "id": "224640", 10 | "title": "Commodo in ut incididunt", 11 | "image": { 12 | "large": "https://soundcloud.img/avatars-000002118384-2t03fi-t500x500.jpg", 13 | "medium": "https://soundcloud.img/avatars-000002118384-2t03fi-t300x300.jpg", 14 | "small": "https://soundcloud.img/avatars-000002118384-2t03fi-large.jpg" 15 | } 16 | }, 17 | "aspect_ratio": 1.0, 18 | "favourite_count": 717, 19 | "provider_id": "soundcloud", 20 | "duration": 202, 21 | "image": { 22 | "large": "https://soundcloud.img/artworks-000050938695-1ez4p1-t500x500.jpg", 23 | "medium": "https://soundcloud.img/artworks-000050938695-1ez4p1-t300x300.jpg", 24 | "small": "https://soundcloud.img/artworks-000050938695-1ez4p1-large.jpg" 25 | } 26 | }, 27 | { 28 | "id": "3002250667522", 29 | "title": "Qui est deserunt laboris minim nulla elit", 30 | "created": "2015-09-22T16:58:42+00:00", 31 | "play_count": 0, 32 | "account": { 33 | "provider_id": "soundcloud", 34 | "id": "351446002", 35 | "title": "Do officia in excepteur", 36 | "image": { 37 | "large": "https://soundcloud.img/avatars-000416214912-zh0osr-t500x500.jpg", 38 | "medium": "https://soundcloud.img/avatars-000416214912-zh0osr-t300x300.jpg", 39 | "small": "https://soundcloud.img/avatars-000416214912-zh0osr-large.jpg" 40 | } 41 | }, 42 | "aspect_ratio": 1.0, 43 | "favourite_count": 501986, 44 | "provider_id": "soundcloud", 45 | "duration": 271, 46 | "image": { 47 | "large": "https://soundcloud.img/artworks-000130366369-im1z4d-t500x500.jpg", 48 | "medium": "https://soundcloud.img/artworks-000130366369-im1z4d-t300x300.jpg", 49 | "small": "https://soundcloud.img/artworks-000130366369-im1z4d-large.jpg" 50 | } 51 | }, 52 | { 53 | "id": "347793270002", 54 | "title": "Nisi do aliquip tempor mollit", 55 | "created": "2017-10-20T15:35:52+00:00", 56 | "play_count": 0, 57 | "account": { 58 | "provider_id": "soundcloud", 59 | "id": "1677324663001", 60 | "title": "Cillum pariatur cupidatat", 61 | "image": { 62 | "large": "https://soundcloud.img/avatars-000342725910-qxkhlj-t500x500.jpg", 63 | "medium": "https://soundcloud.img/avatars-000342725910-qxkhlj-t300x300.jpg", 64 | "small": "https://soundcloud.img/avatars-000342725910-qxkhlj-large.jpg" 65 | } 66 | }, 67 | "aspect_ratio": 1.0, 68 | "favourite_count": 97, 69 | "provider_id": "soundcloud", 70 | "duration": 3414, 71 | "image": { 72 | "large": "https://soundcloud.img/artworks-000248090995-pi2m6q-t500x500.jpg", 73 | "medium": "https://soundcloud.img/artworks-000248090995-pi2m6q-t300x300.jpg", 74 | "small": "https://soundcloud.img/artworks-000248090995-pi2m6q-large.jpg" 75 | } 76 | } 77 | ] 78 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/upstream/youtube/videos.json: -------------------------------------------------------------------------------- 1 | { 2 | "items": [ 3 | { 4 | "id": "PDZcqBgCS74", 5 | "snippet": { 6 | "publishedAt": "2006-03-18T22:40:09.000Z", 7 | "channelId": "UCJFwqPzrd3p2qu7-L6WZuBQ", 8 | "title": "Lionel Richie - Hello", 9 | "thumbnails": { 10 | "default": { 11 | "url": "https://i.ytimg.com/vi/PDZcqBgCS74/default.jpg" 12 | }, 13 | "medium": { 14 | "url": "https://i.ytimg.com/vi/PDZcqBgCS74/mqdefault.jpg" 15 | }, 16 | "high": { 17 | "url": "https://i.ytimg.com/vi/PDZcqBgCS74/hqdefault.jpg" 18 | } 19 | }, 20 | "channelTitle": "jasoneric" 21 | }, 22 | "contentDetails": { 23 | "duration": "PT5M32S" 24 | }, 25 | "statistics": { 26 | "viewCount": "68567907", 27 | "likeCount": "214569" 28 | }, 29 | "player": { 30 | "embedHeight": "240", 31 | "embedWidth": "320" 32 | } 33 | }, 34 | { 35 | "id": "kK42LZqO0wA", 36 | "snippet": { 37 | "publishedAt": "2010-10-01T09:08:44.000Z", 38 | "channelId": "UCb3tJ5NKw7mDxyaQ73mwbRg", 39 | "title": "Martin Solveig & Dragonette - Hello (Official Short Video Version HD)", 40 | "thumbnails": { 41 | "default": { 42 | "url": "https://i.ytimg.com/vi/kK42LZqO0wA/default.jpg" 43 | }, 44 | "medium": { 45 | "url": "https://i.ytimg.com/vi/kK42LZqO0wA/mqdefault.jpg" 46 | }, 47 | "high": { 48 | "url": "https://i.ytimg.com/vi/kK42LZqO0wA/hqdefault.jpg" 49 | } 50 | }, 51 | "channelTitle": "Kontor.TV" 52 | }, 53 | "contentDetails": { 54 | "duration": "PT4M13S" 55 | }, 56 | "statistics": { 57 | "viewCount": "75918775", 58 | "likeCount": "309720" 59 | }, 60 | "player": { 61 | "embedHeight": "180", 62 | "embedWidth": "320" 63 | } 64 | }, 65 | { 66 | "id": "yL6odWvurCI", 67 | "snippet": { 68 | "publishedAt": "2018-06-10T15:00:03.000Z", 69 | "channelId": "UChi7ZkzGovVqYNEbetu6MQA", 70 | "title": "Huỳnh Thanh Thảo - Hello | Tập 4 Vòng Giấu Mặt | The Voice - Giọng Hát Việt 2018", 71 | "thumbnails": { 72 | "default": { 73 | "url": "https://i.ytimg.com/vi/yL6odWvurCI/default.jpg" 74 | }, 75 | "medium": { 76 | "url": "https://i.ytimg.com/vi/yL6odWvurCI/mqdefault.jpg" 77 | }, 78 | "high": { 79 | "url": "https://i.ytimg.com/vi/yL6odWvurCI/hqdefault.jpg" 80 | } 81 | }, 82 | "channelTitle": "Giọng Hát Việt / The Voice Vietnam" 83 | }, 84 | "contentDetails": { 85 | "duration": "PT11M19S" 86 | }, 87 | "statistics": { 88 | "viewCount": "1444595", 89 | "likeCount": "8678" 90 | }, 91 | "player": { 92 | "embedHeight": "180", 93 | "embedWidth": "320" 94 | } 95 | } 96 | ] 97 | } 98 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/track.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.track 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import datetime 9 | 10 | import isodate 11 | 12 | from cloudplayer.api.access import Allow, Everyone, Fields, Read 13 | from cloudplayer.api.model import Transient 14 | from cloudplayer.api.model.account import Account 15 | from cloudplayer.api.model.image import Image 16 | 17 | 18 | class Track(Transient): 19 | 20 | __acl__ = ( 21 | Allow(Everyone, Read, Fields( 22 | 'id', 23 | 'provider_id', 24 | 'account.id', 25 | 'account.provider_id', 26 | 'account.title', 27 | 'account.image.small', 28 | 'account.image.medium', 29 | 'account.image.large', 30 | 'aspect_ratio', 31 | 'duration', 32 | 'favourite_count', 33 | 'image.small', 34 | 'image.medium', 35 | 'image.large', 36 | 'play_count', 37 | 'title', 38 | 'created' 39 | )), 40 | ) 41 | 42 | id = None 43 | provider_id = None 44 | 45 | account = None 46 | 47 | aspect_ratio = None 48 | duration = None 49 | favourite_count = None 50 | image = None 51 | play_count = None 52 | title = None 53 | 54 | @classmethod 55 | def from_provider(cls, provider_id, track): 56 | if provider_id == 'soundcloud': 57 | return cls.from_soundcloud(track) 58 | elif provider_id == 'youtube': 59 | return cls.from_youtube(track) 60 | else: 61 | raise ValueError('unsupported provider') 62 | 63 | @classmethod 64 | def from_soundcloud(cls, track): 65 | user = track['user'] 66 | artist = Account( 67 | id=user['id'], 68 | provider_id='soundcloud', 69 | title=user['username'], 70 | image=Image.from_soundcloud(user.get('avatar_url'))) 71 | 72 | return cls( 73 | id=track['id'], 74 | provider_id='soundcloud', 75 | account=artist, 76 | aspect_ratio=1.0, 77 | duration=int(track['duration'] / 1000.0), 78 | favourite_count=track.get('favoritings_count', 0), 79 | image=Image.from_soundcloud(track.get('artwork_url')), 80 | play_count=track.get('playback_count', 0), 81 | title=track['title'], 82 | created=datetime.datetime.strptime( 83 | track['created_at'], '%Y/%m/%d %H:%M:%S %z')) 84 | 85 | @classmethod 86 | def from_youtube(cls, track): 87 | snippet = track['snippet'] 88 | player = track['player'] 89 | statistics = track['statistics'] 90 | duration = isodate.parse_duration(track['contentDetails']['duration']) 91 | 92 | artist = Account( 93 | id=snippet['channelId'], 94 | provider_id='youtube', 95 | image=None, 96 | title=snippet['channelTitle']) 97 | 98 | return cls( 99 | id=track['id'], 100 | provider_id='youtube', 101 | account=artist, 102 | aspect_ratio=( 103 | float(player['embedHeight']) / float(player['embedWidth'])), 104 | duration=int(duration.total_seconds()), 105 | favourite_count=statistics.get('likeCount', 0), 106 | image=Image.from_youtube(snippet.get('thumbnails')), 107 | play_count=statistics.get('viewCount', 0), 108 | title=snippet['title'], 109 | created=datetime.datetime.strptime( 110 | snippet['publishedAt'], '%Y-%m-%dT%H:%M:%S.%fZ')) 111 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/access/test_policy.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | import pytest 3 | import sqlalchemy as sql 4 | 5 | from cloudplayer.api.access.action import Create, Delete, Query, Read, Update 6 | from cloudplayer.api.access.fields import Available, Fields 7 | from cloudplayer.api.access.policy import Policy, PolicyViolation 8 | from cloudplayer.api.access.rule import Grant 9 | from cloudplayer.api.model import Base 10 | 11 | 12 | def test_policy_grant_should_invoke_rules(): 13 | account = mock.Mock() 14 | action = mock.Mock(__call__=lambda s, _: s) 15 | target = mock.Mock(__call__=lambda s, _: s) 16 | fields = Fields('one', 'two', 'three') 17 | r1 = mock.MagicMock(return_value=None) 18 | r2 = mock.MagicMock(side_effect=PolicyViolation) 19 | target.__acl__ = (r1, r2) 20 | with pytest.raises(PolicyViolation): 21 | Policy.grant(account, action, target, fields) 22 | r1.assert_called_once_with(account, action, target, fields) 23 | r2.assert_called_once_with(account, action, target, fields) 24 | 25 | 26 | def test_policy_grant_should_default_to_violation(): 27 | rule = mock.MagicMock(return_value=None) 28 | target = mock.Mock() 29 | target.__acl__ = (rule, rule, rule) 30 | with pytest.raises(PolicyViolation): 31 | Policy.grant(mock.Mock(), mock.Mock(), target, Available) 32 | 33 | 34 | def test_policy_should_grant_create_for_available_fields(): 35 | account = mock.Mock() 36 | entity = mock.Mock() 37 | grant = Grant(account, Create, entity, Available) 38 | rule = mock.MagicMock(return_value=grant) 39 | entity.__acl__ = (rule,) 40 | policy = Policy(None, None) 41 | policy.grant_create(account, entity, Available) 42 | rule.assert_called_once_with(account, Create, entity, Available) 43 | 44 | 45 | def test_policy_should_grant_read_for_available_fields(): 46 | account = mock.Mock() 47 | entity = mock.Mock() 48 | grant = Grant(account, Read, entity, Available) 49 | rule = mock.MagicMock(return_value=grant) 50 | entity.__acl__ = (rule,) 51 | policy = Policy(None, None) 52 | policy.grant_read(account, entity, Available) 53 | rule.assert_called_once_with(account, Read, entity, Available) 54 | 55 | 56 | def test_policy_should_grant_update_for_specified_fields(): 57 | account = mock.Mock() 58 | entity = mock.Mock() 59 | fields = Fields('one', 'two', 'six') 60 | grant = Grant(account, Update, entity, fields) 61 | rule = mock.MagicMock(return_value=grant) 62 | entity.__acl__ = (rule,) 63 | fields = ('one', 'two', 'six') 64 | policy = Policy(None, None) 65 | policy.grant_update(account, entity, fields) 66 | assert rule.call_args[0][:-1] == (account, Update, entity) 67 | assert rule.call_args[0][-1] in Fields(*fields) 68 | 69 | 70 | def test_policy_should_grant_delete_for_available_fields(): 71 | account = mock.Mock() 72 | entity = mock.Mock() 73 | grant = Grant(account, Delete, entity, Available) 74 | rule = mock.MagicMock(return_value=grant) 75 | entity.__acl__ = (rule,) 76 | policy = Policy(None, None) 77 | policy.grant_delete(account, entity) 78 | rule.assert_called_once_with(account, Delete, entity, Available) 79 | 80 | 81 | class MyModel(Base): 82 | one = sql.Column(sql.Integer, primary_key=True) 83 | two = sql.Column(sql.Integer) 84 | six = sql.Column(sql.Integer) 85 | 86 | 87 | def test_policy_should_grant_query_for_specified_fields(db): 88 | account = mock.Mock() 89 | fields = Fields('one', 'two', 'six') 90 | grant = Grant(account, Query, MyModel, fields) 91 | rule = mock.MagicMock(return_value=grant) 92 | MyModel.__acl__ = (rule,) 93 | kw = {'one': 1, 'two': 2, 'six': 6} 94 | policy = Policy(db, None) 95 | policy.grant_query(account, MyModel, kw) 96 | c_principal, c_action, c_target, c_fields = rule.call_args[0] 97 | assert c_principal == account 98 | assert c_action == Query 99 | assert isinstance(c_target, MyModel) 100 | assert c_fields in Fields(*fields) 101 | -------------------------------------------------------------------------------- /src/cloudplayer/api/handler.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.handler 3 | ~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import redis 9 | import tornado.auth 10 | import tornado.escape 11 | import tornado.httputil 12 | import tornado.web 13 | from tornado.log import app_log, gen_log 14 | 15 | from cloudplayer.api import APIException 16 | 17 | 18 | class HandlerMixin(object): 19 | 20 | @property 21 | def cache(self): 22 | if not hasattr(self, '_cache'): 23 | self._cache = redis.Redis( 24 | connection_pool=self.application.redis_pool) 25 | return self._cache 26 | 27 | @property 28 | def pubsub(self): 29 | if not hasattr(self, '_pubsub'): 30 | self._pubsub = self.cache.pubsub() 31 | self._pubsub.subscribe(keep_alive=lambda _: True) 32 | return self._pubsub 33 | 34 | @pubsub.setter 35 | def pubsub(self, value): 36 | self._pubsub = value 37 | self._pubsub.subscribe(keep_alive=lambda _: True) 38 | 39 | @property 40 | def db(self): 41 | if not hasattr(self, '_db'): 42 | self._db = self.application.database.create_session() 43 | return self._db 44 | 45 | def on_finish(self): 46 | if hasattr(self, '_db'): 47 | self._db.close() 48 | 49 | def write_error(self, status_code, **kw): 50 | self.write({'status_code': status_code, 'reason': self._reason}) 51 | 52 | def _request_summary(self): 53 | return '%s %s %s (%s)' % ( 54 | self.request.protocol.upper(), 55 | self.request.method.upper(), 56 | self.request.uri, 57 | self.request.remote_ip) 58 | 59 | def log_exception(self, type_, value, tb): 60 | if isinstance(value, APIException): 61 | if value.log_message: 62 | args = (value.status_code, self._request_summary(), 63 | value.log_message % value.args) 64 | gen_log.warning('%d %s: %s', *args) 65 | else: 66 | app_log.error('uncaught exception %s', self._request_summary(), 67 | exc_info=(type_, value, tb)) 68 | 69 | 70 | class ControllerHandlerMixin(object): 71 | 72 | __controller__ = NotImplemented 73 | 74 | @property 75 | def controller(self): 76 | if not hasattr(self, '_controller'): 77 | self._controller = self.__controller__( 78 | self.db, self.current_user, self.pubsub) 79 | return self._controller 80 | 81 | 82 | class EntityMixin(ControllerHandlerMixin): 83 | 84 | SUPPORTED_METHODS = ('GET', 'PUT', 'PATCH', 'DELETE', 'SUB', 'UNSUB') 85 | 86 | async def get(self, **ids): 87 | entity = await self.controller.read(ids) 88 | self.write(entity) 89 | 90 | async def put(self, **ids): 91 | entity = await self.controller.update(ids, self.body) 92 | self.write(entity) 93 | 94 | async def patch(self, **ids): 95 | entity = await self.controller.update(ids, self.body) 96 | self.write(entity) 97 | 98 | async def delete(self, **ids): 99 | await self.controller.delete(ids) 100 | self.set_status(204) 101 | self.finish() 102 | 103 | async def sub(self, **ids): 104 | await self.controller.sub(ids, {self.request.channel: self.forward}) 105 | self.set_status(204) 106 | self.finish() 107 | 108 | async def unsub(self, **ids): 109 | await self.controller.unsub(ids, {self.request.channel: None}) 110 | self.set_status(204) 111 | self.finish() 112 | 113 | 114 | class CollectionMixin(ControllerHandlerMixin): 115 | 116 | SUPPORTED_METHODS = ('GET', 'POST') 117 | 118 | async def get(self, **ids): 119 | query = dict(self.query_params) 120 | entities = await self.controller.search(ids, query) 121 | self.write(entities) 122 | 123 | async def post(self, **ids): 124 | entity = await self.controller.create(ids, self.body) 125 | self.write(entity) 126 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/account.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.model.account 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import sqlalchemy as sql 9 | import sqlalchemy.orm as orm 10 | 11 | from cloudplayer.api.access import (Allow, Deny, Everyone, Fields, Owner, 12 | Query, Read, Update) 13 | from cloudplayer.api.model import Base 14 | 15 | 16 | class Account(Base): 17 | 18 | __acl__ = ( 19 | Allow(Owner, Read, Fields( 20 | 'id', 21 | 'provider_id', 22 | 'user_id', 23 | 'connected', 24 | 'favourite_id', 25 | 'image.id', 26 | 'image.small', 27 | 'image.medium', 28 | 'image.large', 29 | 'title', 30 | 'created', 31 | 'updated' 32 | )), 33 | Allow(Owner, Update, Fields( 34 | 'image', 35 | 'title' 36 | )), 37 | Allow(Everyone, Read, Fields( 38 | 'id', 39 | 'provider_id', 40 | 'image.id', 41 | 'image.small', 42 | 'image.medium', 43 | 'image.large', 44 | 'title' 45 | )), 46 | Allow(Everyone, Query, Fields( 47 | 'id', 48 | 'provider_id', 49 | 'title' 50 | )), 51 | Deny() 52 | ) 53 | __fields__ = ( 54 | 'id', 55 | 'provider_id', 56 | 'user_id', 57 | 'connected', 58 | 'favourite_id', 59 | 'image_id', 60 | 'title', 61 | 'created', 62 | 'updated' 63 | ) 64 | __channel__ = ( 65 | 'account.{provider_id}.{id}', 66 | ) 67 | __table_args__ = ( 68 | sql.PrimaryKeyConstraint( 69 | 'id', 'provider_id'), 70 | sql.ForeignKeyConstraint( 71 | ['provider_id'], 72 | ['provider.id']), 73 | sql.ForeignKeyConstraint( 74 | ['user_id'], 75 | ['user.id']), 76 | sql.ForeignKeyConstraint( 77 | ['image_id'], 78 | ['image.id']) 79 | ) 80 | 81 | id = sql.Column(sql.String(32)) 82 | account_id = orm.synonym('id') 83 | 84 | provider_id = sql.Column(sql.String(16), nullable=False) 85 | provider = orm.relation( 86 | 'Provider', 87 | cascade=None, 88 | uselist=False, 89 | viewonly=True) 90 | account_provider_id = orm.synonym('provider_id') 91 | 92 | user_id = sql.Column(sql.Integer, nullable=False) 93 | user = orm.relation( 94 | 'User', 95 | back_populates='accounts', 96 | uselist=False, 97 | viewonly=True) 98 | parent = orm.synonym('user') 99 | 100 | sessions = orm.relation( 101 | 'Session', 102 | back_populates='account', 103 | cascade='all, delete-orphan', 104 | single_parent=True, 105 | uselist=True) 106 | 107 | image_id = sql.Column(sql.Integer) 108 | image = orm.relation( 109 | 'Image', 110 | cascade='all, delete-orphan', 111 | single_parent=True, 112 | uselist=False) 113 | 114 | @property 115 | def favourite_id(self): 116 | if self.favourite: 117 | return self.favourite.id 118 | 119 | favourite = orm.relation( 120 | 'Favourite', 121 | back_populates='account', 122 | cascade='all, delete-orphan', 123 | single_parent=True, 124 | uselist=False) 125 | 126 | playlists = orm.relation( 127 | 'Playlist', 128 | back_populates='account', 129 | cascade='all, delete-orphan', 130 | single_parent=True, 131 | uselist=True) 132 | 133 | title = sql.Column('title', sql.Unicode(64)) 134 | 135 | access_token = sql.Column(sql.String(256)) 136 | refresh_token = sql.Column(sql.String(256)) 137 | token_expiration = sql.Column(sql.DateTime()) 138 | 139 | @property 140 | def connected(self): 141 | return self.provider_id == 'cloudplayer' or all([ 142 | self.access_token, self.refresh_token]) 143 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/controller/test_token.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from cloudplayer.api.access import PolicyViolation 6 | from cloudplayer.api.controller import ControllerException 7 | from cloudplayer.api.controller.token import TokenController 8 | from cloudplayer.api.model.account import Account 9 | from cloudplayer.api.model.token import Token 10 | from cloudplayer.api.model.user import User 11 | 12 | 13 | @pytest.mark.gen_test 14 | async def test_controller_should_create_new_token_anonymously( 15 | db, current_user): 16 | controller = TokenController(db, current_user) 17 | token = await controller.create({}, {}) 18 | assert token.id is not None 19 | assert token.account_id is None 20 | assert token.account_provider_id is None 21 | assert token.claimed is False 22 | 23 | 24 | @pytest.mark.gen_test 25 | async def test_controller_should_not_create_token_with_kw(db, current_user): 26 | controller = TokenController(db, current_user) 27 | with pytest.raises(PolicyViolation): 28 | await controller.create({}, {'account_id': '1234'}) 29 | 30 | 31 | @pytest.mark.gen_test 32 | async def test_controller_should_not_find_non_existent_entities( 33 | db, current_user): 34 | controller = TokenController(db, current_user) 35 | with pytest.raises(ControllerException): 36 | await controller.read({'id': 'not-an-id'}) 37 | 38 | 39 | @pytest.mark.gen_test 40 | async def test_controller_should_not_find_expired_entities(db, current_user): 41 | entity = Token() 42 | db.add(entity) 43 | db.commit() 44 | entity.created = entity.created - datetime.timedelta(minutes=100) 45 | db.commit() 46 | controller = TokenController(db, current_user) 47 | with pytest.raises(ControllerException): 48 | await controller.read({'id': entity.id}) 49 | 50 | 51 | @pytest.mark.gen_test 52 | async def test_controller_should_find_unclaimed_entites(db, current_user): 53 | entity = Token() 54 | db.add(entity) 55 | db.commit() 56 | controller = TokenController(db, current_user) 57 | token = await controller.read({'id': entity.id}) 58 | assert token.claimed is False 59 | assert token.id == entity.id 60 | 61 | 62 | @pytest.mark.gen_test 63 | async def test_controller_should_set_current_user_on_claimed_token( 64 | db, current_user): 65 | entity = Token() 66 | account = Account( 67 | id='1234', user=User(), provider_id='cloudplayer') 68 | entity.account = account 69 | entity.claimed = True 70 | db.add_all((entity, entity.account, entity.account.user)) 71 | db.commit() 72 | 73 | assert entity.account.id != current_user['cloudplayer'] 74 | 75 | controller = TokenController(db, current_user) 76 | token = await controller.read({'id': entity.id}) 77 | assert token.claimed is True 78 | assert token.id == entity.id 79 | assert account.user.id == current_user['user_id'] 80 | 81 | db.refresh(entity) 82 | assert entity.account_id is None 83 | assert entity.account_provider_id is None 84 | 85 | 86 | @pytest.mark.gen_test 87 | async def test_token_entity_should_update_claimed_attribute(db, current_user): 88 | entity = Token() 89 | db.add(entity) 90 | db.commit() 91 | 92 | token = { 93 | 'id': entity.id, 94 | 'claimed': True, 95 | 'account_id': current_user['cloudplayer'], 96 | 'account_provider_id': 'cloudplayer'} 97 | controller = TokenController(db, current_user) 98 | await controller.update({'id': entity.id}, token) 99 | 100 | db.refresh(entity) 101 | assert entity.claimed is True 102 | assert entity.account_id == current_user['cloudplayer'] 103 | assert entity.account_provider_id == 'cloudplayer' 104 | 105 | 106 | @pytest.mark.gen_test 107 | async def test_controller_should_expect_full_update_not_patch( 108 | db, current_user): 109 | entity = Token() 110 | db.add(entity) 111 | db.commit() 112 | 113 | token = {'id': entity.id, 'claimed': True} 114 | controller = TokenController(db, current_user) 115 | with pytest.raises(ControllerException): 116 | await controller.update({'id': entity.id}, token) 117 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/http/test_auth.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from unittest import mock 4 | import asynctest 5 | import pytest 6 | import tornado.web 7 | 8 | from cloudplayer.api.http.auth import AuthHandler, Soundcloud, Youtube 9 | 10 | 11 | @pytest.mark.gen_test 12 | async def test_soundcloud_auth_redirects_with_arguments( 13 | base_url, http_client): 14 | future = http_client.fetch( 15 | '{}/soundcloud'.format(base_url), 16 | follow_redirects=False, raise_error=False) 17 | import tornado.gen 18 | response = await tornado.gen.convert_yielded(future) 19 | assert response.headers['Location'] == ( 20 | 'https://soundcloud.com/connect?' 21 | 'redirect_uri=sc.to%2Fauth&' 22 | 'client_id=sc-key&' 23 | 'response_type=code&' 24 | 'state=testing') 25 | 26 | 27 | @pytest.mark.gen_test 28 | async def test_youtube_auth_redirects_with_arguments(base_url, http_client): 29 | response = await http_client.fetch( 30 | '{}/youtube'.format(base_url), 31 | follow_redirects=False, raise_error=False) 32 | assert response.headers['Location'] == ( 33 | 'https://accounts.google.com/o/oauth2/auth?' 34 | 'redirect_uri=yt.to%2Fauth&' 35 | 'client_id=yt-key&' 36 | 'response_type=code&' 37 | 'approval_prompt=auto&' 38 | 'access_type=offline&' 39 | 'scope=profile+email+' 40 | 'https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fyoutube') 41 | 42 | 43 | @pytest.mark.parametrize('provider_id', ['soundcloud', 'youtube']) 44 | @pytest.mark.gen_test 45 | async def test_auth_handler_redirects_to_close_html_on_provider_callback( 46 | base_url, http_client, monkeypatch, provider_id): 47 | callback = asynctest.CoroutineMock() 48 | monkeypatch.setattr(AuthHandler, 'provider_callback', callback) 49 | response = yield http_client.fetch( 50 | '{}/{}?code=42'.format(base_url, provider_id), 51 | follow_redirects=False, raise_error=False) 52 | assert response.headers['Location'] == '/static/close.html' 53 | callback.assert_called_once() 54 | 55 | 56 | @pytest.mark.gen_test 57 | async def test_auth_handler_closes_with_html_even_on_callback_failure( 58 | base_url, http_client, monkeypatch): 59 | callback = asynctest.CoroutineMock(side_effect=tornado.web.HTTPError(400)) 60 | monkeypatch.setattr(AuthHandler, 'provider_callback', callback) 61 | response = yield http_client.fetch( 62 | '{}/soundcloud?code=42'.format(base_url), 63 | follow_redirects=False, raise_error=False) 64 | assert response.headers['Location'] == '/static/close.html' 65 | callback.assert_called_once() 66 | 67 | 68 | @pytest.mark.gen_test 69 | async def test_auth_handler_should_fetch_soundcloud_user_with_body( 70 | base_url, http_client, monkeypatch): 71 | responses = [{ 72 | 'access_token': '1234', 73 | 'expires_in': 21599, 74 | 'scope': '*', 75 | 'refresh_token': '1234' 76 | }, { 77 | 'id': 1234, 78 | 'avatar_url': 'img.co/large.jpg', 79 | 'username': 'foo bar' 80 | }] 81 | response = mock.Mock() 82 | 83 | async def fetch(*args, **kw): 84 | response.body = json.dumps(responses.pop(0)) 85 | return response 86 | 87 | monkeypatch.setattr(Soundcloud, 'fetch_async', fetch) 88 | 89 | response = await http_client.fetch( 90 | '{}/soundcloud?code=42'.format(base_url), 91 | follow_redirects=False, raise_error=False) 92 | assert 'tok_v1' in response.headers['Set-Cookie'] 93 | 94 | 95 | @pytest.mark.gen_test 96 | async def test_auth_handler_should_fetch_youtube_user_with_body( 97 | base_url, http_client, monkeypatch): 98 | responses = [{ 99 | 'access_token': '1234', 100 | 'token_type': 'Bearer', 101 | 'expires_in': 3599, 102 | 'id_token': '1234', 103 | 'refresh_token': '1234' 104 | }, { 105 | 'id': 'xyz', 106 | 'snippet': { 107 | 'thumbnails': { 108 | 'default': {'url': 'img.xy/default.jpg'}, 109 | 'medium': {'url': 'img.xy/medium.jpg'}, 110 | 'high': {'url': 'img.xy/high.jpg'}}, 111 | 'title': 'foo bar' 112 | } 113 | }] 114 | response = mock.Mock() 115 | 116 | async def fetch(*args, **kw): 117 | response.body = json.dumps(responses.pop(0)) 118 | return response 119 | 120 | monkeypatch.setattr(Youtube, 'fetch_async', fetch) 121 | 122 | response = await http_client.fetch( 123 | '{}/youtube?code=42'.format(base_url), 124 | follow_redirects=False, raise_error=False) 125 | assert 'tok_v1' in response.headers['Set-Cookie'] 126 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.auth 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import traceback 9 | import urllib 10 | 11 | import tornado.auth 12 | import tornado.escape 13 | import tornado.httputil 14 | import tornado.options as opt 15 | import tornado.web 16 | 17 | from cloudplayer.api.controller.auth import SoundcloudAuthController 18 | from cloudplayer.api.controller.auth import YoutubeAuthController 19 | from cloudplayer.api.handler import ControllerHandlerMixin 20 | from cloudplayer.api.http import HTTPHandler 21 | 22 | 23 | class AuthHandler( 24 | ControllerHandlerMixin, HTTPHandler, tornado.auth.OAuth2Mixin): 25 | 26 | _OAUTH_NO_CALLBACKS = False 27 | _OAUTH_VERSION = '1.0a' 28 | _OAUTH_RESPONSE_TYPE = 'code' 29 | _OAUTH_GRANT_TYPE = 'authorization_code' 30 | 31 | _OAUTH_SCOPE_LIST = [] 32 | _OAUTH_EXTRA_PARAMS = {} 33 | 34 | _OAUTH_AUTHORIZE_URL = NotImplemented 35 | _OAUTH_USERINFO_URL = NotImplemented 36 | 37 | @property 38 | def _OAUTH_CLIENT_ID(self): 39 | return opt.options[self.__provider__]['key'] 40 | 41 | @property 42 | def _OAUTH_CLIENT_SECRET(self): 43 | return opt.options[self.__provider__]['secret'] 44 | 45 | @property 46 | def _OAUTH_REDIRECT_URI(self): 47 | return opt.options[self.__provider__]['redirect_uri'] 48 | 49 | async def get(self): 50 | if self.get_argument('code', None) is not None: 51 | try: 52 | await self.provider_callback() 53 | except: # NOQA 54 | traceback.print_exc() 55 | finally: 56 | self.redirect('/static/close.html') 57 | else: 58 | await self.authorize_redirect( 59 | redirect_uri=self._OAUTH_REDIRECT_URI, 60 | client_id=self._OAUTH_CLIENT_ID, 61 | scope=self._OAUTH_SCOPE_LIST, 62 | response_type=self._OAUTH_RESPONSE_TYPE, 63 | extra_params=self._OAUTH_EXTRA_PARAMS) 64 | 65 | async def fetch_async(self, url, **kw): 66 | return await self.controller.fetch_async(url, **kw) 67 | 68 | async def fetch_access(self): 69 | """Fetches the authenticated user data upon redirect""" 70 | body = urllib.parse.urlencode({ 71 | 'client_id': self._OAUTH_CLIENT_ID, 72 | 'client_secret': self._OAUTH_CLIENT_SECRET, 73 | 'code': self.get_argument('code'), 74 | 'grant_type': self._OAUTH_GRANT_TYPE, 75 | 'redirect_uri': self._OAUTH_REDIRECT_URI}) 76 | 77 | response = await self.fetch_async( 78 | self.controller.OAUTH_ACCESS_TOKEN_URL, 79 | headers={'Content-Type': 'application/x-www-form-urlencoded'}, 80 | method='POST', 81 | body=body) 82 | return tornado.escape.json_decode(response.body) 83 | 84 | async def fetch_account(self, access_info): 85 | uri = tornado.httputil.url_concat( 86 | self._OAUTH_USERINFO_URL, 87 | {'access_token': True, 88 | self.controller.OAUTH_TOKEN_PARAM: access_info['access_token']}) 89 | response = await self.fetch_async(uri) 90 | return tornado.escape.json_decode(response.body) 91 | 92 | async def provider_callback(self): 93 | # exchange oauth code for access_token 94 | access_info = await self.fetch_access() 95 | # retrieve account info using access_token 96 | account_info = await self.fetch_account(access_info) 97 | # update or create a new account for this provider 98 | self.controller.update_account(access_info, account_info) 99 | 100 | # sync user account info back to cookie 101 | self.current_user['user_id'] = self.controller.account.user_id 102 | for p in opt.options['providers']: 103 | self.current_user[p] = None 104 | for a in self.controller.account.user.accounts: 105 | self.current_user[a.provider_id] = a.id 106 | self.set_user_cookie() 107 | 108 | 109 | class Soundcloud(AuthHandler): 110 | 111 | __controller__ = SoundcloudAuthController 112 | 113 | __provider__ = 'soundcloud' 114 | _OAUTH_AUTHORIZE_URL = 'https://soundcloud.com/connect' 115 | _OAUTH_USERINFO_URL = 'https://api.soundcloud.com/me' 116 | _OAUTH_SCOPE_LIST = [] 117 | 118 | @property 119 | def _OAUTH_EXTRA_PARAMS(self): 120 | return {'state': self.settings['redirect_state']} 121 | 122 | 123 | class Youtube(AuthHandler): 124 | 125 | __controller__ = YoutubeAuthController 126 | 127 | __provider__ = 'youtube' 128 | _OAUTH_AUTHORIZE_URL = 'https://accounts.google.com/o/oauth2/auth' 129 | _OAUTH_USERINFO_URL = ('https://www.googleapis.com/youtube/v3/channels' 130 | '?part=snippet&mine=true') 131 | _OAUTH_SCOPE_LIST = [ 132 | 'profile', 'email', 'https://www.googleapis.com/auth/youtube'] 133 | _OAUTH_EXTRA_PARAMS = { 134 | 'approval_prompt': 'auto', 'access_type': 'offline'} 135 | -------------------------------------------------------------------------------- /src/cloudplayer/api/http/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.http.base 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import json 9 | 10 | import jwt 11 | import jwt.exceptions 12 | import tornado.auth 13 | import tornado.escape 14 | import tornado.httputil 15 | import tornado.web 16 | 17 | from cloudplayer.api import APIException 18 | from cloudplayer.api.handler import HandlerMixin 19 | from cloudplayer.api.model import Encoder 20 | from cloudplayer.api.model.account import Account 21 | from cloudplayer.api.model.favourite import Favourite 22 | from cloudplayer.api.model.user import User 23 | 24 | 25 | class HTTPException(APIException): 26 | 27 | def __init__(self, status_code=500, log_message='internal server error'): 28 | super().__init__(status_code, log_message) 29 | 30 | 31 | class HTTPHandler(HandlerMixin, tornado.web.RequestHandler): 32 | 33 | SUPPORTED_METHODS = ('GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS') 34 | 35 | def __init__(self, request, application): 36 | super().__init__(request, application) 37 | self.current_user = None 38 | self.original_user = None 39 | 40 | def load_user(self): 41 | try: 42 | user = jwt.decode( 43 | self.get_cookie(self.settings['jwt_cookie'], ''), 44 | self.settings['jwt_secret'], 45 | algorithms=['HS256']) 46 | return user, user.copy() 47 | except jwt.exceptions.InvalidTokenError: 48 | new_user = User() 49 | self.db.add(new_user) 50 | self.db.commit() 51 | new_account = Account( 52 | id=str(new_user.id), 53 | provider_id='cloudplayer', 54 | favourite=Favourite(), 55 | user_id=new_user.id) 56 | self.db.add(new_account) 57 | self.db.commit() 58 | user = {p: None for p in self.settings['providers']} 59 | user['cloudplayer'] = new_account.id 60 | user['user_id'] = new_user.id 61 | return None, user 62 | 63 | def prepare(self): 64 | self.original_user, self.current_user = self.load_user() 65 | 66 | def set_user_cookie(self): 67 | user_jwt = jwt.encode( 68 | self.current_user, 69 | self.settings['jwt_secret'], 70 | algorithm='HS256') 71 | super().set_cookie( 72 | self.settings['jwt_cookie'], 73 | user_jwt, 74 | domain=self.settings['public_domain'], 75 | expires_days=self.settings['jwt_expiration'], 76 | secure=self.settings['public_scheme'] == 'https', 77 | httponly=True) 78 | 79 | def clear_user_cookie(self): 80 | super().clear_cookie(self.settings['jwt_cookie']) 81 | 82 | def set_default_headers(self): 83 | headers = [ 84 | ('Access-Control-Allow-Credentials', 'true'), 85 | ('Access-Control-Allow-Headers', 'Accept, Content-Type, Origin'), 86 | ('Access-Control-Allow-Methods', self.allowed_methods), 87 | ('Access-Control-Allow-Origin', self.allowed_origin), 88 | ('Access-Control-Max-Age', '600'), 89 | ('Cache-Control', 'no-cache, no-store, must-revalidate'), 90 | ('Content-Language', 'en-US'), 91 | ('Content-Type', 'application/json'), 92 | ('Pragma', 'no-cache'), 93 | ('Server', 'cloudplayer') 94 | ] 95 | for header, value in headers: 96 | self.set_header(header, value) 97 | 98 | def write(self, data): 99 | if data is None: 100 | raise HTTPException(404) 101 | json.dump(data, super(), cls=Encoder) 102 | self.finish() 103 | 104 | def finish(self, chunk=None): 105 | if self.original_user != self.current_user: 106 | self.set_user_cookie() 107 | elif not self.current_user: 108 | self.clear_user_cookie() 109 | super().finish(chunk=chunk) 110 | 111 | @property 112 | def body(self): 113 | if not self.request.body: 114 | return {} 115 | try: 116 | return tornado.escape.json_decode(self.request.body) 117 | except (json.decoder.JSONDecodeError, ValueError): 118 | raise HTTPException(400, 'invalid json body') 119 | 120 | @property 121 | def query_params(self): 122 | params = [] 123 | for name, vals in self.request.query_arguments.items(): 124 | for v in vals: 125 | params.append((name, v.decode('utf-8'))) 126 | return params 127 | 128 | async def options(self, *args, **kw): 129 | self.finish() 130 | 131 | @property 132 | def allowed_origin(self): 133 | proposed_origin = self.request.headers.get('Origin') 134 | if proposed_origin in self.settings['allowed_origins']: 135 | return proposed_origin 136 | return self.settings['allowed_origins'][0] 137 | 138 | @property 139 | def allowed_methods(self): 140 | return ', '.join(self.SUPPORTED_METHODS) 141 | 142 | 143 | class HTTPFallback(HTTPHandler): 144 | 145 | SUPPORTED_METHODS = ('GET',) 146 | 147 | async def get(self, *args, **kwargs): 148 | raise HTTPException(404, 'endpoint not found') 149 | 150 | 151 | class HTTPHealth(HTTPHandler): 152 | 153 | SUPPORTED_METHODS = ('GET',) 154 | 155 | async def get(self, *args, **kwargs): 156 | self.cache.info('server') 157 | self.db.execute('SELECT 1 = 1;').first() 158 | self.write({'status_code': 200, 'reason': 'OK'}) 159 | -------------------------------------------------------------------------------- /src/cloudplayer/api/ws/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.ws.base 3 | ~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import json 9 | import sys 10 | import time 11 | 12 | from tornado.log import app_log 13 | from tornado.escape import _unicode 14 | import tornado.concurrent 15 | import tornado.websocket 16 | import tornado.httputil 17 | import tornado.routing 18 | 19 | from cloudplayer.api import APIException 20 | from cloudplayer.api.model import Encoder 21 | from cloudplayer.api.handler import HandlerMixin 22 | 23 | 24 | WS_CODES = { 25 | 1000: 'Normal Closure', 26 | 1001: 'Going Away', 27 | 1002: 'Protocol Error', 28 | 1003: 'Unsupported Data', 29 | 1005: 'No Status Recvd', 30 | 1006: 'Abnormal Closure', 31 | 1007: 'Invalid frame payload data', 32 | 1008: 'Policy Violation', 33 | 1009: 'Message too big', 34 | 1010: 'Missing Extension', 35 | 1011: 'Internal Error', 36 | 1012: 'Service Restart', 37 | 1013: 'Try Again Later', 38 | 1014: 'Bad Gateway', 39 | 1015: 'TLS Handshake' 40 | } 41 | 42 | 43 | class WSException(APIException): 44 | 45 | def __init__(self, status_code=1011, log_message='internal server error'): 46 | super().__init__(status_code, log_message) 47 | 48 | 49 | class WSRequest(object): 50 | 51 | def __init__(self, connection, pubsub, current_user, http_request, 52 | instruction): 53 | self.protocol = 'ws' 54 | self.connection = connection 55 | self.pubsub = pubsub 56 | self.current_user = current_user 57 | self.remote_ip = http_request.remote_ip 58 | self.body = instruction.get('body', {}) 59 | self.method = instruction.get('method', 'GET') 60 | self.query = instruction.get('query', {}) 61 | self.channel = self.path = instruction.get('channel', 'null') 62 | self.sequence = instruction.get('sequence', 0) 63 | self._start_time = time.time() 64 | self._finish_time = None 65 | 66 | @property 67 | def uri(self): 68 | return self.channel 69 | 70 | def finish(self): 71 | self._finish_time = time.time() 72 | 73 | def request_time(self): 74 | if self._finish_time is None: 75 | return time.time() - self._start_time 76 | else: 77 | return self._finish_time - self._start_time 78 | 79 | 80 | class WSBase(object): 81 | 82 | SUPPORTED_METHODS = ('GET', 'PATCH', 'POST', 'DELETE') 83 | 84 | def __init__(self, application, request, path_args=[], path_kwargs={}): 85 | self.application = application 86 | self.request = request 87 | self.pubsub = request.pubsub 88 | self.path_args = [self.decode_argument(arg) for arg in path_args] 89 | self.path_kwargs = dict((k, self.decode_argument(v, name=k)) 90 | for (k, v) in path_kwargs.items()) 91 | self.current_user = request.current_user 92 | self.body = request.body 93 | self.query_params = request.query 94 | self._status_code = 200 95 | self._reason = None 96 | 97 | async def __call__(self): 98 | if self.request.method.upper() not in self.SUPPORTED_METHODS: 99 | raise WSException(405, 'method not allowed') 100 | 101 | method = getattr(self, self.request.method.lower()) 102 | result = await method(*self.path_args, **self.path_kwargs) 103 | if result is not None: 104 | result = await result 105 | 106 | def decode_argument(self, value, name=None): 107 | try: 108 | return _unicode(value) 109 | except UnicodeDecodeError: 110 | raise WSException(400, 'invalid unicode argument') 111 | 112 | def write(self, data): 113 | message = json.dumps( 114 | {'channel': self.request.channel, 115 | 'sequence': self.request.sequence, 116 | 'body': data}, 117 | cls=Encoder) 118 | self.request.connection.write_message(message) 119 | self.on_finish() 120 | 121 | def forward(self, data): 122 | message = data['data'].decode('utf-8') 123 | self.request.connection.write_message(message) 124 | 125 | def finish(self): 126 | self.request.finish() 127 | 128 | def _handle_request_exception(self, exception): 129 | self.log_exception(*sys.exc_info()) 130 | if isinstance(exception, APIException): 131 | self.send_error(exception.status_code, exc_info=sys.exc_info()) 132 | if 1000 <= exception.status_code <= 1015: 133 | app_log.error('closing socket %s', self._reason) 134 | self.request.connection.close( 135 | exception.status_code, self._reason) 136 | self.request.connection = None 137 | else: 138 | self.send_error(500, exc_info=sys.exc_info()) 139 | 140 | def send_error(self, status_code=500, **kw): 141 | reason = kw.get('reason') 142 | if 'exc_info' in kw: 143 | exception = kw['exc_info'][1] 144 | if isinstance(exception, APIException) and exception.log_message: 145 | reason = exception.log_message 146 | self.set_status(status_code, reason=reason) 147 | self.write_error(status_code, **kw) 148 | 149 | def set_status(self, status_code, reason=None): 150 | self._status_code = status_code 151 | if reason: 152 | self._reason = reason 153 | elif status_code in tornado.httputil.responses: 154 | self._reason = tornado.httputil.responses[status_code] 155 | elif status_code in WS_CODES: 156 | self._reason = WS_CODES[status_code] 157 | else: 158 | self._reason = 'no reason given' 159 | 160 | def get_status(self): 161 | return self._status_code 162 | 163 | 164 | class WSHandler(HandlerMixin, WSBase): 165 | pass 166 | 167 | 168 | class WSFallback(WSHandler): 169 | 170 | async def get(self, **kw): 171 | raise WSException(404, 'channel not found') 172 | -------------------------------------------------------------------------------- /src/cloudplayer/api/controller/track.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.controller.track 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import datetime 9 | import itertools 10 | import random 11 | import traceback 12 | import urllib.parse 13 | 14 | import tornado.escape 15 | import tornado.gen 16 | import tornado.options as opt 17 | 18 | from cloudplayer.api.access import Available 19 | from cloudplayer.api.controller import Controller, ControllerException 20 | from cloudplayer.api.model.track import Track 21 | from cloudplayer.api.util import chunk_range, squeeze 22 | 23 | 24 | class TrackController(Controller): 25 | 26 | MAX_RESULTS = 50 27 | 28 | async def search(self, ids, kw, fields=Available): 29 | futures = [] 30 | for provider_id in opt.options.providers: 31 | try: 32 | controller = self.for_provider( 33 | provider_id, self.db, self.current_user) 34 | except ValueError: 35 | continue 36 | local_ids = kw.copy() 37 | local_ids['provider_id'] = provider_id 38 | future = controller.search(local_ids, kw, fields=fields) 39 | futures.append(future) 40 | entities = await tornado.gen.multi(futures) 41 | tracks = list(itertools.chain(*entities)) 42 | random.Random(kw.get('q', '')).shuffle(tracks) 43 | return tracks 44 | 45 | 46 | class SoundcloudTrackController(TrackController): 47 | 48 | __provider__ = 'soundcloud' 49 | 50 | SEARCH_DURATION = { 51 | 'any': {}, 52 | 'long': { 53 | 'duration[from]': 54 | int(datetime.timedelta(minutes=20).total_seconds() * 1000) + 1 55 | }, 56 | 'medium': { 57 | 'duration[from]': 58 | int(datetime.timedelta(minutes=4).total_seconds() * 1000) + 1, 59 | 'duration[to]': 60 | int(datetime.timedelta(minutes=20).total_seconds() * 1000) 61 | }, 62 | 'short': { 63 | 'duration[to]': 64 | int(datetime.timedelta(minutes=4).total_seconds() * 1000) 65 | } 66 | } 67 | 68 | async def read(self, ids, fields=Available): 69 | response = await self.fetch( 70 | ids['provider_id'], '/tracks/{}'.format(ids['id'])) 71 | track = tornado.escape.json_decode(response.body) 72 | entity = Track.from_soundcloud(track) 73 | account = self.get_account(entity.provider_id) 74 | self.policy.grant_read(account, entity, fields) 75 | return entity 76 | 77 | async def search(self, ids, kw, fields=Available): 78 | params = { 79 | 'q': kw.get('q'), 80 | 'filter': 'public', 81 | 'limit': self.MAX_RESULTS} 82 | if 'duration' in kw: 83 | duration = self.SEARCH_DURATION.get(kw['duration'], {}) 84 | params.update(duration.copy()) 85 | response = await self.fetch( 86 | ids['provider_id'], '/tracks', params=params) 87 | track_list = tornado.escape.json_decode(response.body) 88 | entities = [] 89 | account = self.get_account(ids['provider_id']) 90 | for track in track_list: 91 | try: 92 | entity = Track.from_soundcloud(track) 93 | except (KeyError, ValueError): 94 | traceback.print_exc() 95 | continue 96 | self.policy.grant_read(account, entity, fields) 97 | entities.append(entity) 98 | return entities 99 | 100 | 101 | class YoutubeTrackController(TrackController): 102 | 103 | __provider__ = 'youtube' 104 | 105 | MREAD_FIELDS = squeeze(""" 106 | items( 107 | id, 108 | snippet( 109 | channelId, 110 | channelTitle, 111 | thumbnails( 112 | default/url, 113 | medium/url, 114 | high/url), 115 | title, 116 | publishedAt), 117 | contentDetails/duration, 118 | statistics( 119 | viewCount, 120 | likeCount), 121 | player( 122 | embedWidth, 123 | embedHeight)) 124 | """) 125 | 126 | SEARCH_FIELDS = squeeze(""" 127 | items/id/videoId 128 | """) 129 | 130 | async def read(self, ids, fields=Available): 131 | kw = {'ids': [ids.pop('id')]} 132 | entities = await self.mread(ids, kw, fields=fields) 133 | if entities: 134 | return entities[0] 135 | 136 | async def mread(self, ids, kw, fields=Available): 137 | params = { 138 | 'part': 'snippet,contentDetails,player,statistics', 139 | 'fields': self.MREAD_FIELDS, 140 | 'maxWidth': '320'} 141 | if 'ids' in kw: 142 | params['id'] = ','.join( 143 | urllib.parse.quote(i, safe='') for i in kw['ids']) 144 | elif 'rating' in kw: 145 | params['myRating'] = kw['rating'] 146 | params['maxResults'] = self.MAX_RESULTS 147 | else: 148 | raise ControllerException(400, 'missing ids or rating') 149 | response = await self.fetch( 150 | ids['provider_id'], '/videos', params=params) 151 | track_list = tornado.escape.json_decode(response.body) 152 | entities = [] 153 | account = self.get_account(ids['provider_id']) 154 | for track in track_list['items']: 155 | try: 156 | entity = Track.from_youtube(track) 157 | except (KeyError, ValueError): 158 | traceback.print_exc() 159 | continue 160 | self.policy.grant_read(account, entity, fields) 161 | entities.append(entity) 162 | return entities 163 | 164 | async def search(self, ids, kw, fields=Available): 165 | params = { 166 | 'q': kw.get('q'), 167 | 'part': 'snippet', 168 | 'type': 'video', 169 | 'videoEmbeddable': 'true', 170 | 'videoSyndicated': 'true', 171 | 'maxResults': self.MAX_RESULTS, 172 | 'fields': self.SEARCH_FIELDS} 173 | if kw.get('duration') in ('any', 'long', 'medium', 'short'): 174 | params['videoDuration'] = kw['duration'] 175 | response = await self.fetch( 176 | ids['provider_id'], '/search', params=params) 177 | search_result = tornado.escape.json_decode(response.body) 178 | video_ids = [i['id']['videoId'] for i in search_result['items']] 179 | 180 | futures = [] 181 | for i, j in chunk_range(len(video_ids)): 182 | tracks = self.mread(ids, {'ids': video_ids[i: j]}, fields=fields) 183 | futures.append(tracks) 184 | 185 | entities = await tornado.gen.multi(futures) 186 | return list(itertools.chain(*entities)) 187 | -------------------------------------------------------------------------------- /src/cloudplayer/api/tests/test_handler.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | import sys 3 | 4 | import asynctest 5 | import pytest 6 | 7 | import cloudplayer.api.handler 8 | from cloudplayer.api import APIException 9 | from cloudplayer.api.handler import (CollectionMixin, ControllerHandlerMixin, 10 | EntityMixin, HandlerMixin) 11 | 12 | 13 | def test_handler_mixin_should_connect_redis_pool(app): 14 | class Handler(HandlerMixin): 15 | application = app 16 | 17 | handler = Handler() 18 | assert handler.cache.info() 19 | 20 | 21 | def test_handler_mixin_should_create_db_session(app): 22 | class Handler(HandlerMixin): 23 | application = app 24 | 25 | handler = Handler() 26 | assert handler.db.get_bind() is app.database.engine 27 | 28 | 29 | def test_handler_mixin_should_close_db_on_finish(app): 30 | class Base(object): 31 | def finish(self, chunk=None): 32 | self.finished = True 33 | self.on_finish() 34 | 35 | class Handler(HandlerMixin, Base): 36 | application = app 37 | 38 | handler = Handler() 39 | trans = handler.db.begin(subtransactions=True) 40 | assert trans.is_active 41 | handler.finish() 42 | assert not trans.is_active 43 | assert handler.finished 44 | 45 | 46 | def test_handler_should_write_errors_to_out_proto(): 47 | class Handler(HandlerMixin): 48 | write = asynctest.MagicMock() 49 | _reason = 'we-are-not-gonna-take-it' 50 | 51 | Handler().write_error(418) 52 | Handler.write.assert_called_once_with( 53 | {'status_code': 418, 'reason': Handler._reason}) 54 | 55 | 56 | @pytest.mark.gen_test 57 | async def test_handler_should_log_api_exceptions(req, monkeypatch, base_url): 58 | class Handler(HandlerMixin): 59 | request = req 60 | 61 | handler = Handler() 62 | logger = mock.MagicMock() 63 | monkeypatch.setattr(cloudplayer.api.handler, 'gen_log', logger) 64 | try: 65 | raise APIException(418, 'very-bad-exception') 66 | except APIException: 67 | handler.log_exception(*sys.exc_info()) 68 | 69 | cargs = logger.warning.call_args[0] 70 | expected = '418 HTTP GET %s (0.0.0.0): very-bad-exception' % base_url 71 | assert expected == (cargs[0] % cargs[1:]) 72 | 73 | 74 | @pytest.mark.gen_test 75 | async def test_handler_should_log_arbitrary_exceptions( 76 | req, monkeypatch, base_url): 77 | class Handler(HandlerMixin): 78 | request = req 79 | 80 | handler = Handler() 81 | logger = mock.MagicMock() 82 | monkeypatch.setattr(cloudplayer.api.handler, 'app_log', logger) 83 | try: 84 | raise ValueError('wild-value-error') 85 | except ValueError: 86 | handler.log_exception(*sys.exc_info()) 87 | 88 | cargs = logger.error.call_args[0] 89 | expected = 'uncaught exception HTTP GET %s (0.0.0.0)' % base_url 90 | assert (cargs[0] % cargs[1]).startswith(expected) 91 | assert 'exc_info' in logger.error.call_args[1] 92 | 93 | 94 | def test_controller_handler_mixin_should_create_controller(db, current_user): 95 | class ControllerHandler(ControllerHandlerMixin): 96 | __controller__ = mock.MagicMock(return_value=42) 97 | 98 | def __init__(self, db, current_user=None, pubsub=None): 99 | self.db = db 100 | self.pubsub = pubsub 101 | self.current_user = current_user 102 | 103 | handler = ControllerHandler(db, current_user) 104 | assert handler.controller == 42 105 | ControllerHandler.__controller__.assert_called_once_with( 106 | db, current_user, None) 107 | 108 | 109 | def test_entity_mixin_supports_only_valid_methods(): 110 | methods = {'GET', 'PUT', 'PATCH', 'DELETE', 'SUB', 'UNSUB'} 111 | assert set(EntityMixin.SUPPORTED_METHODS) == methods 112 | 113 | 114 | class DummyHandler(object): 115 | 116 | def __init__(self, ctrl): 117 | self.__controller__ = ctrl 118 | self.db = None 119 | self.pubsub = None 120 | self.current_user = None 121 | 122 | def write(self, chunk): 123 | self.written = chunk 124 | 125 | def set_status(self, status): 126 | self.status = status 127 | 128 | def finish(self): 129 | self.finished = True 130 | 131 | 132 | class DummyController(object): 133 | 134 | def __init__(self, db, current_user=None, pubsub=None): 135 | self.db = db 136 | self.pubsub = pubsub 137 | self.current_user = current_user 138 | 139 | async def mirror(self, ids, body=None): 140 | return {'ids': ids, 'body': body} 141 | 142 | create = read = update = delete = search = mirror 143 | 144 | 145 | @pytest.mark.gen_test 146 | async def test_entity_mixin_reads_ids_from_controller(): 147 | handler = type('Reader', (EntityMixin, DummyHandler), {})(DummyController) 148 | ids = {'pkey': 'foo', 'fkey': 'bar'} 149 | await handler.get(**ids) 150 | assert handler.written['ids'] == ids 151 | 152 | 153 | @pytest.mark.gen_test 154 | async def test_entity_mixin_updates_body_for_ids_on_controller(): 155 | handler = type('Updater', (EntityMixin, DummyHandler), { 156 | 'body': '42'})(DummyController) 157 | ids = {'pkey': 'foo', 'fkey': 'bar'} 158 | await handler.put(**ids) 159 | assert handler.written['ids'] == ids 160 | 161 | 162 | @pytest.mark.gen_test 163 | async def test_entity_mixin_patches_body_for_ids_on_controller(): 164 | handler = type('Patcher', (EntityMixin, DummyHandler), { 165 | 'body': '42'})(DummyController) 166 | ids = {'pkey': 'foo', 'fkey': 'bar'} 167 | await handler.patch(**ids) 168 | assert handler.written['ids'] == ids 169 | 170 | 171 | @pytest.mark.gen_test 172 | async def test_entity_mixin_deletes_entity_by_ids(): 173 | handler = type('Deleter', (EntityMixin, DummyHandler), {})(DummyController) 174 | ids = {'pkey': 'foo', 'fkey': 'bar'} 175 | await handler.delete(**ids) 176 | assert handler.status == 204 177 | assert handler.finished 178 | 179 | 180 | def test_collection_mixin_supports_only_valid_methods(): 181 | methods = {'GET', 'POST'} 182 | assert set(CollectionMixin.SUPPORTED_METHODS) == methods 183 | 184 | 185 | @pytest.mark.gen_test 186 | async def test_collection_mixin_creates_new_entities(): 187 | handler = type('Creator', (CollectionMixin, DummyHandler), { 188 | 'body': {'attrib': '73'}})(DummyController) 189 | ids = {'fkey': 'foo'} 190 | await handler.post(**ids) 191 | assert handler.written['ids'] == ids 192 | assert handler.written['body'] == {'attrib': '73'} 193 | 194 | 195 | @pytest.mark.gen_test 196 | async def test_collection_mixin_searches_controller(): 197 | handler = type('Searcher', (CollectionMixin, DummyHandler), { 198 | 'query_params': {'q': '42'}})(DummyController) 199 | ids = {'fkey': 'foo'} 200 | await handler.get(**ids) 201 | assert handler.written['ids'] == ids 202 | assert handler.written['body'] == {'q': '42'} 203 | -------------------------------------------------------------------------------- /src/cloudplayer/api/model/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | cloudplayer.api.base.model 3 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2018 by Nicolas Drebenstedt 6 | :license: GPL-3.0, see LICENSE for details 7 | """ 8 | import datetime 9 | import json 10 | import time 11 | 12 | import redis 13 | import redis.exceptions 14 | import sqlalchemy as sql 15 | import sqlalchemy.inspection 16 | from sqlalchemy.ext.compiler import compiles 17 | from sqlalchemy.ext.declarative import declarative_base, declared_attr 18 | from sqlalchemy.orm import RelationshipProperty 19 | from sqlalchemy.sql import expression 20 | from sqlalchemy.types import DateTime 21 | from tornado.log import app_log 22 | 23 | from cloudplayer.api.access import Deny, Fields 24 | 25 | 26 | class utcnow(expression.FunctionElement): 27 | type = DateTime() 28 | 29 | 30 | @compiles(utcnow, 'postgresql') 31 | def pg_utcnow(element, compiler, **kw): 32 | return "TIMEZONE('utc', CURRENT_TIMESTAMP)" 33 | 34 | 35 | class Transient(object): 36 | 37 | __acl__ = (Deny(),) 38 | __channel__ = () 39 | __fields__ = () 40 | 41 | def __init__(self, **kw): 42 | for field, value in kw.items(): 43 | if hasattr(self, field): 44 | setattr(self, field, value) 45 | 46 | id = None 47 | account_id = None 48 | provider_id = 'cloudplayer' 49 | account_provider_id = property(lambda self: self.provider_id) 50 | parent = None 51 | created = None 52 | updated = None 53 | 54 | def _inspect_field(self, field): 55 | attr = getattr(self, field) 56 | is_list = isinstance(attr, list) 57 | if not is_list: 58 | attr = [attr] 59 | should_expand = all(isinstance(a, Transient) for a in attr) 60 | return should_expand, is_list 61 | 62 | @property 63 | def fields(self): 64 | return getattr(self, '_fields', Fields()) 65 | 66 | @fields.setter 67 | def fields(self, value): 68 | """Fields can be a set of column names and include dotted syntax. 69 | 70 | A dotted field notation like `foo.bar` instructs the model to look up 71 | its `foo` relation and render its `bar` attribute. 72 | 73 | {'foo': {'bar': 42}} 74 | 75 | If the `foo` relation is one to many, the `bar` attribute is rendered 76 | from all the members in `foo`. 77 | 78 | {'foo': [{'bar': 73}, {'bar': 89}]} 79 | """ 80 | tree = {} 81 | flat = [] 82 | for field in value: 83 | key, *path = field.split('.', 1) 84 | flat.append(key) 85 | if path: 86 | tree[key] = tree.get(key, []) + list(path) 87 | for field, paths in tree.items(): 88 | should_expand, is_list = self._inspect_field(field) 89 | if should_expand: 90 | relations = getattr(self, field) 91 | if not is_list: 92 | relations = [relations] 93 | for relation in relations: 94 | if isinstance(relation, Base): 95 | relation.fields = Fields(*paths) 96 | self._fields = Fields(*flat) 97 | 98 | @property 99 | def account(self): 100 | # XXX: Check session for this account id without querying 101 | from cloudplayer.api.model.account import Account 102 | if self.account_id and self.provider_id: 103 | return Account(id=self.account_id, provider_id=self.provider_id) 104 | 105 | @classmethod 106 | def requires_account(cls): 107 | return False 108 | 109 | 110 | class Model(Transient): 111 | """Abstract model class serving as the sqlalchemy declarative base. 112 | 113 | Defines sensible default values for commonly used attributes 114 | such as timestamps, ownership, acl and nested field expansion. 115 | """ 116 | 117 | @declared_attr 118 | def __tablename__(cls): 119 | return cls.__name__.lower() 120 | 121 | created = sql.Column( 122 | sql.DateTime, server_default=utcnow()) 123 | updated = sql.Column( 124 | sql.DateTime, server_default=utcnow(), onupdate=utcnow()) 125 | 126 | def _inspect_field(self, field): 127 | prop = getattr(type(self), field).property 128 | should_expand = isinstance(prop, RelationshipProperty) 129 | is_list = prop.uselist 130 | return should_expand, is_list 131 | 132 | @property 133 | def account(self): 134 | # TODO: Check session for this account id without querying 135 | from cloudplayer.api.model.account import Account 136 | if self.account_id and self.provider_id: 137 | return Account(id=self.account_id, provider_id=self.provider_id) 138 | 139 | @classmethod 140 | def requires_account(cls): 141 | mapper = sqlalchemy.inspection.inspect(cls) 142 | prop = mapper.relationships.get('account', None) 143 | if prop: 144 | return not all(c.nullable for c in prop.local_columns) 145 | return False 146 | 147 | @staticmethod 148 | def event_hook(redis_pool, method, mapper, connection, target): 149 | target.fields = Fields(*target.__fields__) 150 | cache = redis.Redis(connection_pool=redis_pool) 151 | for pattern in target.__channel__: 152 | channel = pattern.format(**target.__dict__) 153 | message = json.dumps({ 154 | 'channel': channel, 155 | 'method': method, 156 | 'body': target}, 157 | cls=Encoder) 158 | 159 | start = time.time() 160 | try: 161 | cache.publish(channel, message) 162 | except redis.exceptions.ConnectionError: 163 | status_code = 503 164 | host = '::1' 165 | else: 166 | status_code = 200 167 | host = cache.connection_pool.connection_kwargs['host'] 168 | 169 | pub_time = 1000.0 * (time.time() - start) 170 | app_log.info('{} REDIS {} {} ({}) {:.2f}ms'.format( 171 | status_code, method.upper(), channel, host, pub_time)) 172 | 173 | 174 | Base = declarative_base(cls=Model) 175 | 176 | 177 | class Encoder(json.JSONEncoder): 178 | """Custom JSON encoder for rendering granted fields.""" 179 | 180 | def default(self, obj): 181 | try: 182 | return json.JSONEncoder.default(self, obj) 183 | except: # NOQA 184 | if isinstance(obj, Transient): 185 | dict_ = {f: getattr(obj, f) for f in obj.fields} 186 | if dict_.get('id'): # TODO: There must be a better solution 187 | dict_['id'] = str(dict_['id']) 188 | return dict_ 189 | elif isinstance(obj, datetime.datetime): 190 | return obj.isoformat() 191 | elif isinstance(obj, datetime.timedelta): 192 | return obj.total_seconds() 193 | return json.JSONEncoder.default(self, obj) 194 | --------------------------------------------------------------------------------