From 5f40192f1d14bd59df331513d62d19e8535b4535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Thu, 13 Jul 2017 22:02:03 +0200 Subject: [PATCH 01/25] WIP: Added unit tests for UserManager --- tests/__init__.py | 0 tests/test_user_manager.py | 92 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/test_user_manager.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_user_manager.py b/tests/test_user_manager.py new file mode 100644 index 0000000..38ee23c --- /dev/null +++ b/tests/test_user_manager.py @@ -0,0 +1,92 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 +# +# Copyright © 2017 Óscar García Amor +# +# Distributed under terms of the GNU GPLv3 license. + +from supysonic import db +from supysonic.managers.user import UserManager + +import unittest +import uuid + +class UserManagerTestCase(unittest.TestCase): + def setUp(self): + # Create an empty sqlite database in memory + self.store = db.get_store("sqlite:") + # Read schema from file + with open('schema/sqlite.sql') as sql: + schema = sql.read() + # Create tables on memory database + for command in schema.split(';'): + self.store.execute(command) + # Create some users + self.assertEqual(UserManager.add(self.store, 'alice', 'alice', 'test@example.com', True), UserManager.SUCCESS) + self.assertEqual(UserManager.add(self.store, 'bob', 'bob', 'bob@example.com', False), UserManager.SUCCESS) + self.assertEqual(UserManager.add(self.store, 'charlie', 'charlie', 'charlie@example.com', False), UserManager.SUCCESS) + + def test_encrypt_password(self): + self.assertEqual(UserManager._UserManager__encrypt_password('password','salt'), ('59b3e8d637cf97edbe2384cf59cb7453dfe30789', 'salt')) + self.assertEqual(UserManager._UserManager__encrypt_password('pass-word','pepper'), ('d68c95a91ed7773aa57c7c044d2309a5bf1da2e7', 'pepper')) + + def test_get_user(self): + # Get existing users + for name in ['alice', 'bob', 'charlie']: + user = self.store.find(db.User, db.User.name == name).one() + self.assertEqual(UserManager.get(self.store, user.id), (UserManager.SUCCESS, user)) + # Get with invalid UUID + self.assertEqual(UserManager.get(self.store, 'invalid-uuid'), (UserManager.INVALID_ID, None)) + # Non-existent user + self.assertEqual(UserManager.get(self.store, uuid.uuid4()), (UserManager.NO_SUCH_USER, None)) + + def test_add_user(self): + # Create duplicate + self.assertEqual(UserManager.add(self.store, 'alice', 'alice', 'test@example.com', True), UserManager.NAME_EXISTS) + + def test_delete_user(self): + # Delete existing users + for name in ['alice', 'bob', 'charlie']: + user = self.store.find(db.User, db.User.name == name).one() + self.assertEqual(UserManager.delete(self.store, user.id), UserManager.SUCCESS) + # Delete invalid UUID + self.assertEqual(UserManager.delete(self.store, 'invalid-uuid'), UserManager.INVALID_ID) + # Delete non-existent user + self.assertEqual(UserManager.delete(self.store, uuid.uuid4()), UserManager.NO_SUCH_USER) + + def test_try_auth(self): + # Test authentication + for name in ['alice', 'bob', 'charlie']: + user = self.store.find(db.User, db.User.name == name).one() + self.assertEqual(UserManager.try_auth(self.store, name, name), (UserManager.SUCCESS, user)) + # Wrong password + self.assertEqual(UserManager.try_auth(self.store, name, 'bad'), (UserManager.WRONG_PASS, None)) + # Non-existent user + self.assertEqual(UserManager.try_auth(self.store, 'null', 'null'), (UserManager.NO_SUCH_USER, None)) + + def test_change_password(self): + # With existing users + for name in ['alice', 'bob', 'charlie']: + user = self.store.find(db.User, db.User.name == name).one() + # God password + self.assertEqual(UserManager.change_password(self.store, user.id, name, 'newpass'), UserManager.SUCCESS) + self.assertEqual(UserManager.try_auth(self.store, name, 'newpass'), (UserManager.SUCCESS, user)) + # Wrong password + self.assertEqual(UserManager.change_password(self.store, user.id, 'badpass', 'newpass'), UserManager.WRONG_PASS) + # With invalid UUID + self.assertEqual(UserManager.change_password(self.store, 'invalid-uuid', 'oldpass', 'newpass'), UserManager.INVALID_ID) + # Non-existent user + self.assertEqual(UserManager.change_password(self.store, uuid.uuid4(), 'oldpass', 'newpass'), UserManager.NO_SUCH_USER) + + def test_change_password2(self): + # With existing users + for name in ['alice', 'bob', 'charlie']: + self.assertEqual(UserManager.change_password2(self.store, name, 'newpass'), UserManager.SUCCESS) + user = self.store.find(db.User, db.User.name == name).one() + self.assertEqual(UserManager.try_auth(self.store, name, 'newpass'), (UserManager.SUCCESS, user)) + # Non-existent user + self.assertEqual(UserManager.change_password2(self.store, 'null', 'newpass'), UserManager.NO_SUCH_USER) + +if __name__ == '__main__': + unittest.main() From 9fcd4e8c528b551279016a92126c1427ce2c4f2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Fri, 14 Jul 2017 12:14:10 +0200 Subject: [PATCH 02/25] WIP: Added unit tests for FolderManager --- tests/__init__.py | 18 ++++++++ tests/test_folder_manager.py | 87 ++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 tests/test_folder_manager.py diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..7da1e75 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,18 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 +# +# Copyright © 2017 Óscar García Amor +# +# Distributed under terms of the GNU GPLv3 license. + +from .test_folder_manager import FolderManagerTestCase +from .test_user_manager import UserManagerTestCase + +import unittest + +def suite(): + suite = unittest.TestSuite() + suite.addTest(FolderManagerTestCase()) + suite.addTest(UserManagerTestCase()) + return suite diff --git a/tests/test_folder_manager.py b/tests/test_folder_manager.py new file mode 100644 index 0000000..bfd8d66 --- /dev/null +++ b/tests/test_folder_manager.py @@ -0,0 +1,87 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 +# +# Copyright © 2017 Óscar García Amor +# +# Distributed under terms of the GNU GPLv3 license. + +from supysonic import db +from supysonic.managers.folder import FolderManager + +import os +import shutil +import tempfile +import unittest +import uuid + +class FolderManagerTestCase(unittest.TestCase): + def setUp(self): + # Create an empty sqlite database in memory + self.store = db.get_store("sqlite:") + # Read schema from file + with open('schema/sqlite.sql') as sql: + schema = sql.read() + # Create tables on memory database + for command in schema.split(';'): + self.store.execute(command) + # Create some temporary directories + self.media_dir = tempfile.mkdtemp() + self.music_dir = tempfile.mkdtemp() + # Add test folders + self.assertEqual(FolderManager.add(self.store, 'media', self.media_dir), FolderManager.SUCCESS) + self.assertEqual(FolderManager.add(self.store, 'music', self.music_dir), FolderManager.SUCCESS) + folder = db.Folder() + folder.root = False + folder.name = 'non-root' + folder.path = os.path.join(self.music_dir, 'subfolder') + self.store.add(folder) + self.store.commit() + + def tearDown(self): + shutil.rmtree(self.media_dir) + shutil.rmtree(self.music_dir) + + def test_get_folder(self): + # Get existing folders + for name in ['media', 'music']: + folder = self.store.find(db.Folder, db.Folder.name == name, db.Folder.root == True).one() + self.assertEqual(FolderManager.get(self.store, folder.id), (FolderManager.SUCCESS, folder)) + # Get with invalid UUID + self.assertEqual(FolderManager.get(self.store, 'invalid-uuid'), (FolderManager.INVALID_ID, None)) + # Non-existent folder + self.assertEqual(FolderManager.get(self.store, uuid.uuid4()), (FolderManager.NO_SUCH_FOLDER, None)) + + def test_add_folder(self): + # Create duplicate + self.assertEqual(FolderManager.add(self.store,'media', self.media_dir), FolderManager.NAME_EXISTS) + # Duplicate path + self.assertEqual(FolderManager.add(self.store,'new-folder', self.media_dir), FolderManager.PATH_EXISTS) + # Invalid path + self.assertEqual(FolderManager.add(self.store,'invalid-path', os.path.abspath('/this/not/is/valid')), FolderManager.INVALID_PATH) + # Subfolder of already added path + os.mkdir(os.path.join(self.media_dir, 'subfolder')) + self.assertEqual(FolderManager.add(self.store,'subfolder', os.path.join(self.media_dir, 'subfolder')), FolderManager.PATH_EXISTS) + + def test_delete_folder(self): + # Delete existing folders + for name in ['media', 'music']: + folder = self.store.find(db.Folder, db.Folder.name == name, db.Folder.root == True).one() + self.assertEqual(FolderManager.delete(self.store, folder.id), FolderManager.SUCCESS) + # Delete invalid UUID + self.assertEqual(FolderManager.delete(self.store, 'invalid-uuid'), FolderManager.INVALID_ID) + # Delete non-existent folder + self.assertEqual(FolderManager.delete(self.store, uuid.uuid4()), FolderManager.NO_SUCH_FOLDER) + # Delete non-root folder + folder = self.store.find(db.Folder, db.Folder.name == 'non-root').one() + self.assertEqual(FolderManager.delete(self.store, folder.id), FolderManager.NO_SUCH_FOLDER) + + def test_delete_by_name(self): + # Delete existing folders + for name in ['media', 'music']: + self.assertEqual(FolderManager.delete_by_name(self.store, name), FolderManager.SUCCESS) + # Delete non-existent folder + self.assertEqual(FolderManager.delete_by_name(self.store, 'null'), FolderManager.NO_SUCH_FOLDER) + +if __name__ == '__main__': + unittest.main() From d4b30746626fb0ee9d1b3d0d27c66473bff87f72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Fri, 14 Jul 2017 15:23:44 +0200 Subject: [PATCH 03/25] WIP: Adding unit tests for API User --- tests/__init__.py | 18 +++++++++------- tests/test_api_user.py | 49 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 8 deletions(-) create mode 100644 tests/test_api_user.py diff --git a/tests/__init__.py b/tests/__init__.py index 7da1e75..384822a 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -6,13 +6,15 @@ # # Distributed under terms of the GNU GPLv3 license. -from .test_folder_manager import FolderManagerTestCase -from .test_user_manager import UserManagerTestCase +from .test_api_user import ApiUserTestCase +#from .test_folder_manager import FolderManagerTestCase +#from .test_user_manager import UserManagerTestCase -import unittest +#import unittest -def suite(): - suite = unittest.TestSuite() - suite.addTest(FolderManagerTestCase()) - suite.addTest(UserManagerTestCase()) - return suite +#def suite(): +# suite = unittest.TestSuite() +# suite.addTest(ApiUserTestCase()) + #suite.addTest(FolderManagerTestCase()) + #suite.addTest(UserManagerTestCase()) +# return suite diff --git a/tests/test_api_user.py b/tests/test_api_user.py new file mode 100644 index 0000000..4b62250 --- /dev/null +++ b/tests/test_api_user.py @@ -0,0 +1,49 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 +# +# Copyright © 2017 Óscar García Amor +# +# Distributed under terms of the GNU GPLv3 license. + +from supysonic import db +from supysonic.managers.user import UserManager + +import sys +import unittest +import uuid + +class ApiUserTestCase(unittest.TestCase): + def setUp(self): + # Create an empty sqlite database in memory + self.store = db.get_store("sqlite:") + # Read schema from file + with open('schema/sqlite.sql') as sql: + schema = sql.read() + # Create tables on memory database + for command in schema.split(';'): + self.store.execute(command) + # Create some users + self.assertEqual(UserManager.add(self.store, 'alice', 'alice', 'test@example.com', True), UserManager.SUCCESS) + self.assertEqual(UserManager.add(self.store, 'bob', 'bob', 'bob@example.com', False), UserManager.SUCCESS) + self.assertEqual(UserManager.add(self.store, 'charlie', 'charlie', 'charlie@example.com', False), UserManager.SUCCESS) + # Create a mockup of web + from flask import Flask + self.app = Flask(__name__) + class web(): + app = self.app + store = self.store + sys.modules['supysonic.web'] = web() + # Import module and set app in test mode + from supysonic.api.user import user_info + self.app.testing = True + self.app = self.app.test_client() + + def test_user_info(self): + rv = self.app.get('/rest/getUser.view?u=alice&p=alice&c=test') + assert 'message="Missing username"' in rv.data + rv = self.app.get('/rest/getUser.view?u=alice&p=alice&c=test&username=alice') + assert 'adminRole="true"' in rv.data + +if __name__ == '__main__': + unittest.main() From a7ec45f2fde919c04732ddbabec7538593d7ea26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Fri, 14 Jul 2017 17:28:46 +0200 Subject: [PATCH 04/25] WIP: Reorder tests --- tests/test_api.py | 67 +++++++++++++++++++ ...lder_manager.py => test_manager_folder.py} | 0 ...t_user_manager.py => test_manager_user.py} | 0 3 files changed, 67 insertions(+) create mode 100644 tests/test_api.py rename tests/{test_folder_manager.py => test_manager_folder.py} (100%) rename tests/{test_user_manager.py => test_manager_user.py} (100%) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..04b14a1 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,67 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 +# +# Copyright © 2017 Óscar García Amor +# +# Distributed under terms of the GNU GPLv3 license. + +from supysonic import db +from supysonic.managers.user import UserManager + +import sys +import unittest +import uuid + +# Create an empty sqlite database in memory +store = db.get_store("sqlite:") +# Read schema from file +with open('schema/sqlite.sql') as sql: + schema = sql.read() +# Create tables on memory database +for command in schema.split(';'): + store.execute(command) +# Create some users +UserManager.add(store, 'alice', 'alice', 'test@example.com', True) +UserManager.add(store, 'bob', 'bob', 'bob@example.com', False) +UserManager.add(store, 'charlie', 'charlie', 'charlie@example.com', False) + +# Create a mockup of web +from flask import Flask +app = Flask(__name__) +class web(): + app = app + store = store +sys.modules['supysonic.web'] = web() + +# Import module and set app in test mode +#from supysonic.api import user +import supysonic.api + +class ApiTestCase(unittest.TestCase): + def setUp(self): + self.app = app.test_client() + + def test_user_info(self): + # Missing username + rv = self.app.get('/rest/getUser.view?u=alice&p=alice&c=test') + assert 'message="Missing username"' in rv.data + # Non-admin request for other user + rv = self.app.get('/rest/getUser.view?u=bob&p=bob&c=test&username=alice') + assert 'message="Admin restricted"' in rv.data + # Non-existent user + rv = self.app.get('/rest/getUser.view?u=alice&p=alice&c=test&username=null') + assert 'message="Unknown user"' in rv.data + # Admin request + rv = self.app.get('/rest/getUser.view?u=alice&p=alice&c=test&username=alice') + assert 'adminRole="true"' in rv.data + # Non-admin request + rv = self.app.get('/rest/getUser.view?u=bob&p=bob&c=test&username=bob') + assert 'adminRole="false"' in rv.data + + def test_get_users(self): + rv = self.app.get('/rest/getUsers.view?u=alice&p=alice&c=test') + print rv.data + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_folder_manager.py b/tests/test_manager_folder.py similarity index 100% rename from tests/test_folder_manager.py rename to tests/test_manager_folder.py diff --git a/tests/test_user_manager.py b/tests/test_manager_user.py similarity index 100% rename from tests/test_user_manager.py rename to tests/test_manager_user.py From f8254532ed9312e1fdc3cc26e121cc65054bd70f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Fri, 14 Jul 2017 20:57:08 +0200 Subject: [PATCH 05/25] WIP: Added unit tests for API and frontend --- tests/__init__.py | 16 +-- tests/test_api.py | 230 ++++++++++++++++++++++++++++++++++++++--- tests/test_api_user.py | 49 --------- tests/test_frontend.py | 101 ++++++++++++++++++ 4 files changed, 321 insertions(+), 75 deletions(-) delete mode 100644 tests/test_api_user.py create mode 100644 tests/test_frontend.py diff --git a/tests/__init__.py b/tests/__init__.py index 384822a..d3e8bcd 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -6,15 +6,7 @@ # # Distributed under terms of the GNU GPLv3 license. -from .test_api_user import ApiUserTestCase -#from .test_folder_manager import FolderManagerTestCase -#from .test_user_manager import UserManagerTestCase - -#import unittest - -#def suite(): -# suite = unittest.TestSuite() -# suite.addTest(ApiUserTestCase()) - #suite.addTest(FolderManagerTestCase()) - #suite.addTest(UserManagerTestCase()) -# return suite +from .test_manager_folder import FolderManagerTestCase +from .test_manager_user import UserManagerTestCase +from .test_api import ApiTestCase +from .test_frontend import FrontendTestCase diff --git a/tests/test_api.py b/tests/test_api.py index 04b14a1..52c80cf 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -35,33 +35,235 @@ class web(): sys.modules['supysonic.web'] = web() # Import module and set app in test mode -#from supysonic.api import user import supysonic.api class ApiTestCase(unittest.TestCase): def setUp(self): self.app = app.test_client() - def test_user_info(self): - # Missing username + def test_ping(self): + # GET non-existent user + rv = self.app.get('/rest/ping.view?u=null&p=null&c=test') + self.assertIn('status="failed"', rv.data) + self.assertIn('message="Unauthorized"', rv.data) + # POST non-existent user + rv = self.app.post('/rest/ping.view', data=dict(u='null', p='null', c='test')) + self.assertIn('status="failed"', rv.data) + self.assertIn('message="Unauthorized"', rv.data) + # GET user request + rv = self.app.get('/rest/ping.view?u=alice&p=alice&c=test') + self.assertIn('status="ok"', rv.data) + # POST user request + rv = self.app.post('/rest/ping.view', data=dict(u='alice', p='alice', c='test')) + self.assertIn('status="ok"', rv.data) + # GET user request with bad password + rv = self.app.get('/rest/ping.view?u=alice&p=bad&c=test') + self.assertIn('status="failed"', rv.data) + self.assertIn('message="Unauthorized"', rv.data) + # POST user request with bad password + rv = self.app.post('/rest/ping.view', data=dict(u='alice', p='bad', c='test')) + self.assertIn('status="failed"', rv.data) + self.assertIn('message="Unauthorized"', rv.data) + + def test_ping_in_jsonp(self): + # If ping in jsonp works all other endpoints must work OK + # GET non-existent user + rv = self.app.get('/rest/ping.view?u=null&p=null&c=test&f=jsonp&callback=test') + self.assertIn('"status": "failed"', rv.data) + self.assertIn('"message": "Unauthorized"', rv.data) + # POST non-existent user + rv = self.app.post('/rest/ping.view', data=dict(u='null', p='null', c='test', f='jsonp', callback='test')) + self.assertIn('"status": "failed"', rv.data) + self.assertIn('"message": "Unauthorized"', rv.data) + # GET user request + rv = self.app.get('/rest/ping.view?u=alice&p=alice&c=test&f=jsonp&callback=test') + self.assertIn('"status": "ok"', rv.data) + # POST user request + rv = self.app.post('/rest/ping.view', data=dict(u='alice', p='alice', c='test', f='jsonp', callback='test')) + self.assertIn('"status": "ok"', rv.data) + # GET user request with bad password + rv = self.app.get('/rest/ping.view?u=alice&p=bad&c=test&f=jsonp&callback=test') + self.assertIn('"status": "failed"', rv.data) + self.assertIn('"message": "Unauthorized"', rv.data) + # POST user request with bad password + rv = self.app.post('/rest/ping.view', data=dict(u='alice', p='bad', c='test', f='jsonp', callback='test')) + self.assertIn('"status": "failed"', rv.data) + self.assertIn('"message": "Unauthorized"', rv.data) + + def test_not_implemented(self): + # Access to not implemented endpoint + rv = self.app.get('/rest/not-implemented?u=alice&p=alice&c=test') + self.assertIn('message="Not implemented"', rv.data) + rv = self.app.post('/rest/not-implemented', data=dict(u='alice', p='alice', c='test')) + self.assertIn('message="Not implemented"', rv.data) + + def test_get_license(self): + # GET user request + rv = self.app.get('/rest/getLicense.view?u=alice&p=alice&c=test') + self.assertIn('status="ok"', rv.data) + self.assertIn('license valid="true"', rv.data) + # POST user request + rv = self.app.post('/rest/getLicense.view', data=dict(u='alice', p='alice', c='test')) + self.assertIn('status="ok"', rv.data) + self.assertIn('license valid="true"', rv.data) + + def test_get_user(self): + # GET missing username rv = self.app.get('/rest/getUser.view?u=alice&p=alice&c=test') - assert 'message="Missing username"' in rv.data - # Non-admin request for other user + self.assertIn('message="Missing username"', rv.data) + # POST missing username + rv = self.app.post('/rest/getUser.view', data=dict(u='alice', p='alice', c='test')) + self.assertIn('message="Missing username"', rv.data) + # GET non-admin request for other user rv = self.app.get('/rest/getUser.view?u=bob&p=bob&c=test&username=alice') - assert 'message="Admin restricted"' in rv.data - # Non-existent user + self.assertIn('message="Admin restricted"', rv.data) + # POST non-admin request for other user + rv = self.app.post('/rest/getUser.view', data=dict(u='bob', p='bob', c='test', username='alice')) + self.assertIn('message="Admin restricted"', rv.data) + # GET non-existent user rv = self.app.get('/rest/getUser.view?u=alice&p=alice&c=test&username=null') - assert 'message="Unknown user"' in rv.data - # Admin request + self.assertIn('message="Unknown user"', rv.data) + # POST non-existent user + rv = self.app.post('/rest/getUser.view', data=dict(u='alice', p='alice', c='test', username='null')) + self.assertIn('message="Unknown user"', rv.data) + # GET admin request rv = self.app.get('/rest/getUser.view?u=alice&p=alice&c=test&username=alice') - assert 'adminRole="true"' in rv.data - # Non-admin request - rv = self.app.get('/rest/getUser.view?u=bob&p=bob&c=test&username=bob') - assert 'adminRole="false"' in rv.data + self.assertIn('adminRole="true"', rv.data) + self.assertIn('username="alice"', rv.data) + # POST admin request + rv = self.app.post('/rest/getUser.view', data=dict(u='alice', p='alice', c='test', username='alice')) + self.assertIn('adminRole="true"', rv.data) + self.assertIn('username="alice"', rv.data) + # GET admin request for other user + rv = self.app.get('/rest/getUser.view?u=alice&p=alice&c=test&username=bob') + self.assertIn('username="bob"', rv.data) + self.assertIn('adminRole="false"', rv.data) + # POST admin request for other user + rv = self.app.post('/rest/getUser.view', data=dict(u='alice', p='alice', c='test', username='bob')) + self.assertIn('username="bob"', rv.data) + self.assertIn('adminRole="false"', rv.data) + # GET non-admin request + rv = self.app.get('/rest/getUser.view?u=charlie&p=charlie&c=test&username=charlie') + self.assertIn('username="charlie"', rv.data) + self.assertIn('adminRole="false"', rv.data) + # POST non-admin request + rv = self.app.post('/rest/getUser.view', data=dict(u='charlie', p='charlie', c='test', username='charlie')) + self.assertIn('username="charlie"', rv.data) + self.assertIn('adminRole="false"', rv.data) def test_get_users(self): + # GET admin request rv = self.app.get('/rest/getUsers.view?u=alice&p=alice&c=test') - print rv.data + self.assertIn('alice', rv.data) + self.assertIn('bob', rv.data) + self.assertIn('charlie', rv.data) + # POST admin request + rv = self.app.post('/rest/getUsers.view', data=dict(u='alice', p='alice', c='test')) + self.assertIn('alice', rv.data) + self.assertIn('bob', rv.data) + self.assertIn('charlie', rv.data) + # GET non-admin request + rv = self.app.get('/rest/getUsers.view?u=bob&p=bob&c=test') + self.assertIn('message="Admin restricted"', rv.data) + # POST non-admin request + rv = self.app.post('/rest/getUsers.view', data=dict(u='bob', p='bob', c='test')) + self.assertIn('message="Admin restricted"', rv.data) + + def test_create_user(self): + # GET non-admin request + rv = self.app.get('/rest/createUser.view?u=bob&p=bob&c=test') + self.assertIn('message="Admin restricted"', rv.data) + # POST non-admin request + rv = self.app.post('/rest/createUser.view', data=dict(u='bob', p='bob', c='test')) + self.assertIn('message="Admin restricted"', rv.data) + # GET incomplete request + rv = self.app.get('/rest/createUser.view?u=alice&p=alice&c=test') + self.assertIn('message="Missing parameter"', rv.data) + # POST incomplete request + rv = self.app.post('/rest/createUser.view', data=dict(u='alice', p='alice', c='test')) + self.assertIn('message="Missing parameter"', rv.data) + # GET create user and test that user is created + rv = self.app.get('/rest/createUser.view?u=alice&p=alice&c=test&username=david&password=david&email=david%40example.com&adminRole=True') + self.assertIn('status="ok"', rv.data) + rv = self.app.get('/rest/getUser.view?u=david&p=david&c=test&username=david') + self.assertIn('username="david"', rv.data) + self.assertIn('email="david@example.com"', rv.data) + self.assertIn('adminRole="true"', rv.data) + # POST create user and test that user is created + rv = self.app.post('/rest/createUser.view', data=dict(u='alice', p='alice', c='test', username='elanor', password='elanor', email='elanor@example.com', adminRole=True)) + self.assertIn('status="ok"', rv.data) + rv = self.app.post('/rest/getUser.view', data=dict(u='elanor', p='elanor', c='test', username='elanor')) + self.assertIn('username="elanor"', rv.data) + self.assertIn('email="elanor@example.com"', rv.data) + self.assertIn('adminRole="true"', rv.data) + # GET create duplicate + rv = self.app.get('/rest/createUser.view?u=alice&p=alice&c=test&username=david&password=david&email=david%40example.com&adminRole=True') + self.assertIn('message="There is already a user with that username"', rv.data) + # POST create duplicate + rv = self.app.post('/rest/createUser.view', data=dict(u='alice', p='alice', c='test', username='elanor', password='elanor', email='elanor@example.com', adminRole=True)) + self.assertIn('message="There is already a user with that username"', rv.data) + + def test_delete_user(self): + # GET non-admin request + rv = self.app.get('/rest/deleteUser.view?u=bob&p=bob&c=test') + self.assertIn('message="Admin restricted"', rv.data) + # POST non-admin request + rv = self.app.post('/rest/deleteUser.view', data=dict(u='bob', p='bob', c='test')) + self.assertIn('message="Admin restricted"', rv.data) + # GET incomplete request + rv = self.app.get('/rest/deleteUser.view?u=alice&p=alice&c=test') + self.assertIn('message="Unknown user"', rv.data) + # POST incomplete request + rv = self.app.post('/rest/deleteUser.view', data=dict(u='alice', p='alice', c='test')) + self.assertIn('message="Unknown user"', rv.data) + # GET delete non-existent user + rv = self.app.get('/rest/deleteUser.view?u=alice&p=alice&c=test&username=nonexistent') + self.assertIn('message="Unknown user"', rv.data) + # POST delete non-existent user + rv = self.app.post('/rest/deleteUser.view', data=dict(u='alice', p='alice', c='test', username='nonexistent')) + self.assertIn('message="Unknown user"', rv.data) + # GET delete existent user + rv = self.app.get('/rest/deleteUser.view?u=alice&p=alice&c=test&username=elanor') + self.assertIn('status="ok"', rv.data) + rv = self.app.get('/rest/getUser.view?u=alice&p=alice&c=test&username=elanor') + self.assertIn('message="Unknown user"', rv.data) + # POST delete existent user + rv = self.app.post('/rest/deleteUser.view', data=dict(u='alice', p='alice', c='test', username='david')) + self.assertIn('status="ok"', rv.data) + rv = self.app.post('/rest/getUser.view', data=dict(u='alice', p='alice', c='test', username='david')) + self.assertIn('message="Unknown user"', rv.data) + + def test_change_password(self): + # GET incomplete request + rv = self.app.get('/rest/changePassword.view?u=alice&p=alice&c=test') + self.assertIn('message="Missing parameter"', rv.data) + # POST incomplete request + rv = self.app.post('/rest/changePassword.view', data=dict(u='alice', p='alice', c='test')) + self.assertIn('message="Missing parameter"', rv.data) + # GET non-admin change own password + rv = self.app.get('/rest/changePassword.view?u=bob&p=bob&c=test&username=bob&password=newpassword') + self.assertIn('status="ok"', rv.data) + # POST non-admin change own password + rv = self.app.post('/rest/changePassword.view', data=dict(u='bob', p='newpassword', c='test', username='bob', password='bob')) + self.assertIn('status="ok"', rv.data) + # GET non-admin change other user password + rv = self.app.get('/rest/changePassword.view?u=bob&p=bob&c=test&username=alice&password=newpassword') + self.assertIn('message="Admin restricted"', rv.data) + # POST non-admin change other user password + rv = self.app.post('/rest/changePassword.view', data=dict(u='bob', p='bob', c='test', username='alice', password='newpassword')) + self.assertIn('message="Admin restricted"', rv.data) + # GET admin change other user password + rv = self.app.get('/rest/changePassword.view?u=bob&p=bob&c=test&username=bob&password=newpassword') + self.assertIn('status="ok"', rv.data) + # POST admin change other user password + rv = self.app.post('/rest/changePassword.view', data=dict(u='bob', p='newpassword', c='test', username='bob', password='bob')) + self.assertIn('status="ok"', rv.data) + # GET change non-existent user password + rv = self.app.get('/rest/changePassword.view?u=alice&p=alice&c=test&username=nonexistent&password=nonexistent') + self.assertIn('message="No such user"', rv.data) + # POST change non-existent user password + rv = self.app.post('/rest/changePassword.view', data=dict(u='alice', p='alice', c='test', username='nonexistent', password='nonexistent')) + self.assertIn('message="No such user"', rv.data) if __name__ == '__main__': unittest.main() diff --git a/tests/test_api_user.py b/tests/test_api_user.py deleted file mode 100644 index 4b62250..0000000 --- a/tests/test_api_user.py +++ /dev/null @@ -1,49 +0,0 @@ -#! /usr/bin/env python -# -*- coding: utf-8 -*- -# vim:fenc=utf-8 -# -# Copyright © 2017 Óscar García Amor -# -# Distributed under terms of the GNU GPLv3 license. - -from supysonic import db -from supysonic.managers.user import UserManager - -import sys -import unittest -import uuid - -class ApiUserTestCase(unittest.TestCase): - def setUp(self): - # Create an empty sqlite database in memory - self.store = db.get_store("sqlite:") - # Read schema from file - with open('schema/sqlite.sql') as sql: - schema = sql.read() - # Create tables on memory database - for command in schema.split(';'): - self.store.execute(command) - # Create some users - self.assertEqual(UserManager.add(self.store, 'alice', 'alice', 'test@example.com', True), UserManager.SUCCESS) - self.assertEqual(UserManager.add(self.store, 'bob', 'bob', 'bob@example.com', False), UserManager.SUCCESS) - self.assertEqual(UserManager.add(self.store, 'charlie', 'charlie', 'charlie@example.com', False), UserManager.SUCCESS) - # Create a mockup of web - from flask import Flask - self.app = Flask(__name__) - class web(): - app = self.app - store = self.store - sys.modules['supysonic.web'] = web() - # Import module and set app in test mode - from supysonic.api.user import user_info - self.app.testing = True - self.app = self.app.test_client() - - def test_user_info(self): - rv = self.app.get('/rest/getUser.view?u=alice&p=alice&c=test') - assert 'message="Missing username"' in rv.data - rv = self.app.get('/rest/getUser.view?u=alice&p=alice&c=test&username=alice') - assert 'adminRole="true"' in rv.data - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_frontend.py b/tests/test_frontend.py new file mode 100644 index 0000000..d3a77c1 --- /dev/null +++ b/tests/test_frontend.py @@ -0,0 +1,101 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 +# +# Copyright © 2017 Óscar García Amor +# +# Distributed under terms of the GNU GPLv3 license. + +from supysonic import db +from supysonic.managers.user import UserManager + +import sys +import unittest +import uuid + +# Create an empty sqlite database in memory +store = db.get_store("sqlite:") +# Read schema from file +with open('schema/sqlite.sql') as sql: + schema = sql.read() +# Create tables on memory database +for command in schema.split(';'): + store.execute(command) +# Create some users +UserManager.add(store, 'alice', 'alice', 'test@example.com', True) +UserManager.add(store, 'bob', 'bob', 'bob@example.com', False) +UserManager.add(store, 'charlie', 'charlie', 'charlie@example.com', False) + +# Create a mockup of web +from flask import Flask +app = Flask(__name__, template_folder='../supysonic/templates') +class web(): + app = app + store = store +sys.modules['supysonic.web'] = web() + +# Import module and set app in test mode +import supysonic.frontend +app.secret_key = 'test-suite' + +class FrontendTestCase(unittest.TestCase): + def setUp(self): + self.app = app.test_client() + + def test_unauthorized_request(self): + # Unauthorized request + rv = self.app.get('/', follow_redirects=True) + self.assertIn('Please login', rv.data) + + def test_login_with_bad_data(self): + # Login with not blank user or password + rv = self.app.post('/user/login', data=dict(name='', password=''), follow_redirects=True) + self.assertIn('Missing user name', rv.data) + self.assertIn('Missing password', rv.data) + # Login with not valid user or password + rv = self.app.post('/user/login', data=dict(user='nonexistent', password='nonexistent'), follow_redirects=True) + self.assertIn('No such user', rv.data) + rv = self.app.post('/user/login', data=dict(user='alice', password='badpassword'), follow_redirects=True) + self.assertIn('Wrong password', rv.data) + + def test_login_admin(self): + # Login with a valid admin user + rv = self.app.post('/user/login', data=dict(user='alice', password='alice'), follow_redirects=True) + self.assertIn('Logged in', rv.data) + self.assertIn('Users', rv.data) + self.assertIn('Folders', rv.data) + + def test_login_non_admin(self): + # Login with a valid non-admin user + rv = self.app.post('/user/login', data=dict(user='bob', password='bob'), follow_redirects=True) + self.assertIn('Logged in', rv.data) + # Non-admin user cannot acces to users and folders + self.assertNotIn('Users', rv.data) + self.assertNotIn('Folders', rv.data) + + def test_root_with_valid_session(self): + # Root with valid session + with self.app.session_transaction() as sess: + sess['userid'] = store.find(db.User, db.User.name == 'alice').one().id + sess['username'] = 'alice' + rv = self.app.get('/', follow_redirects=True) + self.assertIn('alice', rv.data) + self.assertIn('Log out', rv.data) + self.assertIn('There\'s nothing much to see here.', rv.data) + + def test_root_with_non_valid_session(self): + # Root with a no-valid session + with self.app.session_transaction() as sess: + sess['userid'] = uuid.uuid4() + sess['username'] = 'alice' + rv = self.app.get('/', follow_redirects=True) + self.assertIn('Please login', rv.data) + # Root with a no-valid user + with self.app.session_transaction() as sess: + sess['userid'] = store.find(db.User, db.User.name == 'alice').one().id + sess['username'] = 'nonexistent' + rv = self.app.get('/', follow_redirects=True) + self.assertIn('Please login', rv.data) + +if __name__ == '__main__': + unittest.main() From 6cbd47f29756cfb21315625c916e09f9d53b6af9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Fri, 14 Jul 2017 21:12:55 +0200 Subject: [PATCH 06/25] Added .travis.yml to run tests with Travis CI --- .travis.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..a338343 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,5 @@ +language: python +python: + - "2.7" +install: "pip install -r requirements.txt" +script: "python -m unittest -v tests" From 2a3f13bb82723a942e19d871dbcfa61fca87d5f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Mon, 7 Aug 2017 08:58:32 +0200 Subject: [PATCH 07/25] WIP: Rewrite config.py to make project more clean --- supysonic/config.py | 97 ++++++++++++++++++++++++++++----------------- 1 file changed, 60 insertions(+), 37 deletions(-) diff --git a/supysonic/config.py b/supysonic/config.py index 193252d..9d61cb7 100644 --- a/supysonic/config.py +++ b/supysonic/config.py @@ -1,49 +1,72 @@ -# coding: utf-8 - +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 +# # This file is part of Supysonic. -# # Supysonic is a Python implementation of the Subsonic server API. -# Copyright (C) 2013 Alban 'spl0k' Féron # -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. +# Copyright (C) 2013-2017 Alban 'spl0k' Féron +# 2017 Óscar García Amor # -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . +# Distributed under terms of the GNU AGPLv3 license. -import os, sys, tempfile, ConfigParser +from ConfigParser import ConfigParser, NoOptionError, NoSectionError -config = ConfigParser.RawConfigParser({ 'cache_dir': os.path.join(tempfile.gettempdir(), 'supysonic') }) +import mimetypes +import os +import tempfile -def check(): - try: - ret = config.read([ '/etc/supysonic', os.path.expanduser('~/.supysonic') ]) - except (ConfigParser.MissingSectionHeaderError, ConfigParser.ParsingError), e: - print >>sys.stderr, "Error while parsing the configuration file(s):\n%s" % str(e) - return False +class Config(object): + """ + Config object to work with config file + """ + def __init__(self): + # Seek for standard locations + config_file = [ + 'supysonic.conf', + os.path.expanduser('~/.config/supysonic/supysonic.conf'), + os.path.expanduser('~/.supysonic'), + '/etc/supysonic' + ] + self.config = ConfigParser({ 'cache_dir': os.path.join(tempfile.gettempdir(), 'supysonic') }) + # Try read config file or raise error + try: + self.config.read(config_file) + except Exception as e: + err = 'Config file is corrupted.\n{0}'.format(e) + raise SystemExit(err) - if not ret: - print >>sys.stderr, "No configuration file found" - return False + def check(self): + """ + Checks the config for mandatory fields + """ + try: + self.config.get('base', 'database_uri') + except (NoSectionError, NoOptionError): + raise SystemExit('No database URI set') - try: - config.get('base', 'database_uri') - except: - print >>sys.stderr, "No database URI set" - return False + def get(self, section, option): + """ + Returns a config option value from config file - return True + :param section: section where the option is stored + :param option: option name + :return: a config option value + :rtype: string + """ + try: + return self.config.get(section, option) + except (NoSectionError, NoOptionError): + return None -def get(section, name): - try: - return config.get(section, name) - except: - return None + def get_mime(self, extension): + """ + Returns mimetype of an extension based on config file + :param extension: extension string + :return: mimetype + :rtype: string + """ + guessed_mime = mimetypes.guess_type('dummy.' + extension, False)[0] + config_mime = self.get('mimetypes', extension) + default_mime = 'application/octet-stream' + return guessed_mime or config_mime or default_mime From 6a6bc577cb1f501f8569a79183606b8d61d681fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Mon, 7 Aug 2017 09:04:49 +0200 Subject: [PATCH 08/25] WIP rewrite config.py: update cli --- bin/supysonic-cli | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bin/supysonic-cli b/bin/supysonic-cli index 2aba005..c6d70bc 100755 --- a/bin/supysonic-cli +++ b/bin/supysonic-cli @@ -20,7 +20,7 @@ # along with this program. If not, see . import sys, cmd, argparse, getpass, time -from supysonic import config +from supysonic.config import Config from supysonic.db import get_store, Folder, User from supysonic.managers.folder import FolderManager @@ -225,10 +225,10 @@ class CLI(cmd.Cmd): print "Successfully changed '{}' password".format(name) if __name__ == "__main__": - if not config.check(): + if not Config().check(): sys.exit(1) - cli = CLI(get_store(config.get('base', 'database_uri'))) + cli = CLI(get_store(Config().get('base', 'database_uri'))) if len(sys.argv) > 1: cli.onecmd(' '.join(sys.argv[1:])) else: From 8fe9f9b715e653e51e1763d599863c680f63f272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Mon, 7 Aug 2017 09:34:43 +0200 Subject: [PATCH 09/25] WIP rewrite config.py: change several files to use new config --- supysonic/api/media.py | 15 ++++++++------- supysonic/db.py | 4 ++-- supysonic/frontend/user.py | 6 +++--- supysonic/lastfm.py | 6 +++--- supysonic/scanner.py | 6 +++--- supysonic/watcher.py | 19 ++++++++++--------- 6 files changed, 29 insertions(+), 27 deletions(-) diff --git a/supysonic/api/media.py b/supysonic/api/media.py index f308377..b880875 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -26,7 +26,8 @@ import subprocess import codecs from xml.etree import ElementTree -from supysonic import config, scanner +from supysonic import scanner +from supysonic.config import Config from supysonic.web import app, store from supysonic.db import Track, Album, Artist, Folder, User, ClientPrefs, now from . import get_entity @@ -70,14 +71,14 @@ def stream_media(): if format and format != 'raw' and format != src_suffix: dst_suffix = format - dst_mimetype = scanner.get_mime(dst_suffix) + dst_mimetype = Config().get_mime(dst_suffix) if format != 'raw' and (dst_suffix != src_suffix or dst_bitrate != res.bitrate): - transcoder = config.get('transcoding', 'transcoder_{}_{}'.format(src_suffix, dst_suffix)) - decoder = config.get('transcoding', 'decoder_' + src_suffix) or config.get('transcoding', 'decoder') - encoder = config.get('transcoding', 'encoder_' + dst_suffix) or config.get('transcoding', 'encoder') + transcoder = Config().get('transcoding', 'transcoder_{}_{}'.format(src_suffix, dst_suffix)) + decoder = Config().get('transcoding', 'decoder_' + src_suffix) or Config().get('transcoding', 'decoder') + encoder = Config().get('transcoding', 'encoder_' + dst_suffix) or Config().get('transcoding', 'encoder') if not transcoder and (not decoder or not encoder): - transcoder = config.get('transcoding', 'transcoder') + transcoder = Config().get('transcoding', 'transcoder') if not transcoder: message = 'No way to transcode from {} to {}'.format(src_suffix, dst_suffix) app.logger.info(message) @@ -153,7 +154,7 @@ def cover_art(): if size > im.size[0] and size > im.size[1]: return send_file(os.path.join(res.path, 'cover.jpg')) - size_path = os.path.join(config.get('webapp', 'cache_dir'), str(size)) + size_path = os.path.join(Config().get('webapp', 'cache_dir'), str(size)) path = os.path.join(size_path, str(res.id)) if os.path.exists(path): return send_file(path) diff --git a/supysonic/db.py b/supysonic/db.py index 34f10e6..59340fd 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -27,7 +27,7 @@ from storm.variables import Variable import uuid, datetime, time import os.path -from supysonic import get_mime +from supysonic.config import Config def now(): return datetime.datetime.now().replace(microsecond = 0) @@ -213,7 +213,7 @@ class Track(object): if prefs and prefs.format and prefs.format != self.suffix(): info['transcodedSuffix'] = prefs.format - info['transcodedContentType'] = get_mime(prefs.format) + info['transcodedContentType'] = Config().get_mime(prefs.format) return info diff --git a/supysonic/frontend/user.py b/supysonic/frontend/user.py index 86d237d..a427735 100644 --- a/supysonic/frontend/user.py +++ b/supysonic/frontend/user.py @@ -24,7 +24,7 @@ from supysonic.web import app, store from supysonic.managers.user import UserManager from supysonic.db import User, ClientPrefs import uuid, csv -from supysonic import config +from supysonic.config import Config from supysonic.lastfm import LastFm @app.before_request @@ -43,12 +43,12 @@ def user_index(): def user_profile(uid): if uid == 'me': prefs = store.find(ClientPrefs, ClientPrefs.user_id == uuid.UUID(session.get('userid'))) - return render_template('profile.html', user = UserManager.get(store, session.get('userid'))[1], api_key = config.get('lastfm', 'api_key'), clients = prefs, admin = UserManager.get(store, session.get('userid'))[1].admin) + return render_template('profile.html', user = UserManager.get(store, session.get('userid'))[1], api_key = Config().get('lastfm', 'api_key'), clients = prefs, admin = UserManager.get(store, session.get('userid'))[1].admin) else: if not UserManager.get(store, session.get('userid'))[1].admin or not UserManager.get(store, uid)[0] is UserManager.SUCCESS: return redirect(url_for('index')) prefs = store.find(ClientPrefs, ClientPrefs.user_id == uuid.UUID(uid)) - return render_template('profile.html', user = UserManager.get(store, uid)[1], api_key = config.get('lastfm', 'api_key'), clients = prefs, admin = UserManager.get(store, session.get('userid'))[1].admin) + return render_template('profile.html', user = UserManager.get(store, uid)[1], api_key = Config().get('lastfm', 'api_key'), clients = prefs, admin = UserManager.get(store, session.get('userid'))[1].admin) @app.route('/user/', methods = [ 'POST' ]) def update_clients(uid): diff --git a/supysonic/lastfm.py b/supysonic/lastfm.py index 5a799f3..0d90da3 100644 --- a/supysonic/lastfm.py +++ b/supysonic/lastfm.py @@ -19,13 +19,13 @@ # along with this program. If not, see . import requests, hashlib -from supysonic import config +from supysonic.config import Config class LastFm: def __init__(self, user, logger): self.__user = user - self.__api_key = config.get('lastfm', 'api_key') - self.__api_secret = config.get('lastfm', 'secret') + self.__api_key = Config().get('lastfm', 'api_key') + self.__api_secret = Config().get('lastfm', 'secret') self.__enabled = self.__api_key is not None and self.__api_secret is not None self.__logger = logger diff --git a/supysonic/scanner.py b/supysonic/scanner.py index 3a3683a..c5589d8 100644 --- a/supysonic/scanner.py +++ b/supysonic/scanner.py @@ -25,7 +25,7 @@ import mutagen from storm.expr import ComparableExpr, compile, Like from storm.exceptions import NotSupportedError -from supysonic import config, get_mime +from supysonic.config import Config from supysonic.db import Folder, Artist, Album, Track, User, PlaylistTrack from supysonic.db import StarredFolder, StarredArtist, StarredAlbum, StarredTrack from supysonic.db import RatingFolder, RatingTrack @@ -63,7 +63,7 @@ class Scanner: self.__deleted_albums = 0 self.__deleted_tracks = 0 - extensions = config.get('base', 'scanner_extensions') + extensions = Config().get('base', 'scanner_extensions') self.__extensions = map(str.lower, extensions.split()) if extensions else None self.__folders_to_check = set() @@ -166,7 +166,7 @@ class Scanner: tr.duration = int(tag.info.length) tr.bitrate = (tag.info.bitrate if hasattr(tag.info, 'bitrate') else int(os.path.getsize(path) * 8 / tag.info.length)) / 1000 - tr.content_type = get_mime(os.path.splitext(path)[1][1:]) + tr.content_type = Config().get_mime(os.path.splitext(path)[1][1:]) tr.last_modification = os.path.getmtime(path) tralbum = self.__find_album(albumartist, album) diff --git a/supysonic/watcher.py b/supysonic/watcher.py index e502ff7..7a43d71 100644 --- a/supysonic/watcher.py +++ b/supysonic/watcher.py @@ -26,7 +26,8 @@ from logging.handlers import TimedRotatingFileHandler from watchdog.observers import Observer from watchdog.events import PatternMatchingEventHandler -from supysonic import config, db +from supysonic import db +from supysonic.config import Config from supysonic.scanner import Scanner OP_SCAN = 1 @@ -35,7 +36,7 @@ OP_MOVE = 4 class SupysonicWatcherEventHandler(PatternMatchingEventHandler): def __init__(self, queue, logger): - extensions = config.get('base', 'scanner_extensions') + extensions = Config().get('base', 'scanner_extensions') patterns = map(lambda e: "*." + e.lower(), extensions.split()) if extensions else None super(SupysonicWatcherEventHandler, self).__init__(patterns = patterns, ignore_directories = True) @@ -132,7 +133,7 @@ class ScannerProcessingQueue(Thread): continue self.__logger.debug("Instantiating scanner") - store = db.get_store(config.get('base', 'database_uri')) + store = db.get_store(Config().get('base', 'database_uri')) scanner = Scanner(store) item = self.__next_item() @@ -200,17 +201,17 @@ class ScannerProcessingQueue(Thread): class SupysonicWatcher(object): def run(self): - if not config.check(): + if not Config().check(): return logger = logging.getLogger(__name__) - if config.get('daemon', 'log_file'): - log_handler = TimedRotatingFileHandler(config.get('daemon', 'log_file'), when = 'midnight') + if Config().get('daemon', 'log_file'): + log_handler = TimedRotatingFileHandler(Config().get('daemon', 'log_file'), when = 'midnight') else: log_handler = logging.NullHandler() log_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) logger.addHandler(log_handler) - if config.get('daemon', 'log_level'): + if Config().get('daemon', 'log_level'): mapping = { 'DEBUG': logging.DEBUG, 'INFO': logging.INFO, @@ -218,9 +219,9 @@ class SupysonicWatcher(object): 'ERROR': logging.ERROR, 'CRTICAL': logging.CRITICAL } - logger.setLevel(mapping.get(config.get('daemon', 'log_level').upper(), logging.NOTSET)) + logger.setLevel(mapping.get(Config().get('daemon', 'log_level').upper(), logging.NOTSET)) - store = db.get_store(config.get('base', 'database_uri')) + store = db.get_store(Config().get('base', 'database_uri')) folders = store.find(db.Folder, db.Folder.root == True) if not folders.count(): From 1271a35c2048945b14a9d8733a0a10702f81c5db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Mon, 7 Aug 2017 09:37:37 +0200 Subject: [PATCH 10/25] WIP rewrite config.py: simply retab web.py --- supysonic/web.py | 88 ++++++++++++++++++++++-------------------------- 1 file changed, 40 insertions(+), 48 deletions(-) diff --git a/supysonic/web.py b/supysonic/web.py index 8c35aac..935a1b5 100644 --- a/supysonic/web.py +++ b/supysonic/web.py @@ -1,22 +1,14 @@ -# coding: utf-8 - +#! /usr/bin/env python +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 +# # This file is part of Supysonic. -# # Supysonic is a Python implementation of the Subsonic server API. -# Copyright (C) 2013 Alban 'spl0k' Féron # -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. +# Copyright © 2013-2017 Alban 'spl0k' Féron +# 2017 Óscar García Amor # -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . +# Distributed under terms of the GNU AGPLv3 license. import os.path from flask import Flask, g @@ -26,50 +18,50 @@ from supysonic import config from supysonic.db import get_store def get_db_store(): - store = getattr(g, 'store', None) - if store: - return store - g.store = get_store(config.get('base', 'database_uri')) - return g.store + store = getattr(g, 'store', None) + if store: + return store + g.store = get_store(config.get('base', 'database_uri')) + return g.store store = LocalProxy(get_db_store) def teardown_db(exception): - store = getattr(g, 'store', None) - if store: - store.close() + store = getattr(g, 'store', None) + if store: + store.close() def create_application(): - global app + global app - if not config.check(): - return None + if not config.check(): + return None - if not os.path.exists(config.get('webapp', 'cache_dir')): - os.makedirs(config.get('webapp', 'cache_dir')) + if not os.path.exists(config.get('webapp', 'cache_dir')): + os.makedirs(config.get('webapp', 'cache_dir')) - app = Flask(__name__) - app.secret_key = '?9huDM\\H' + app = Flask(__name__) + app.secret_key = '?9huDM\\H' - app.teardown_appcontext(teardown_db) + app.teardown_appcontext(teardown_db) - if config.get('webapp', 'log_file'): - import logging - from logging.handlers import TimedRotatingFileHandler - handler = TimedRotatingFileHandler(config.get('webapp', 'log_file'), when = 'midnight') - if config.get('webapp', 'log_level'): - mapping = { - 'DEBUG': logging.DEBUG, - 'INFO': logging.INFO, - 'WARNING': logging.WARNING, - 'ERROR': logging.ERROR, - 'CRTICAL': logging.CRITICAL - } - handler.setLevel(mapping.get(config.get('webapp', 'log_level').upper(), logging.NOTSET)) - app.logger.addHandler(handler) + if config.get('webapp', 'log_file'): + import logging + from logging.handlers import TimedRotatingFileHandler + handler = TimedRotatingFileHandler(config.get('webapp', 'log_file'), when = 'midnight') + if config.get('webapp', 'log_level'): + mapping = { + 'DEBUG': logging.DEBUG, + 'INFO': logging.INFO, + 'WARNING': logging.WARNING, + 'ERROR': logging.ERROR, + 'CRTICAL': logging.CRITICAL + } + handler.setLevel(mapping.get(config.get('webapp', 'log_level').upper(), logging.NOTSET)) + app.logger.addHandler(handler) - from supysonic import frontend - from supysonic import api + from supysonic import frontend + from supysonic import api - return app + return app From 81c2fafe860ad97da4144473a731edbd0b9ddc31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Mon, 7 Aug 2017 09:47:39 +0200 Subject: [PATCH 11/25] Rewrite web.py to use new config.py --- supysonic/web.py | 63 +++++++++++++++++++++++++++--------------------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/supysonic/web.py b/supysonic/web.py index 935a1b5..eb1be93 100644 --- a/supysonic/web.py +++ b/supysonic/web.py @@ -1,55 +1,64 @@ -#! /usr/bin/env python # -*- coding: utf-8 -*- # vim:fenc=utf-8 # # This file is part of Supysonic. # Supysonic is a Python implementation of the Subsonic server API. # -# Copyright © 2013-2017 Alban 'spl0k' Féron -# 2017 Óscar García Amor +# Copyright (C) 2013-2017 Alban 'spl0k' Féron +# 2017 Óscar García Amor # # Distributed under terms of the GNU AGPLv3 license. -import os.path from flask import Flask, g +from os import makedirs, path from werkzeug.local import LocalProxy -from supysonic import config +from supysonic.config import Config from supysonic.db import get_store -def get_db_store(): - store = getattr(g, 'store', None) - if store: - return store - g.store = get_store(config.get('base', 'database_uri')) - return g.store +# Supysonic database open +def get_db(): + if not hasattr(g, 'database'): + g.database = get_store(Config().get('base', 'database_uri')) + return g.database -store = LocalProxy(get_db_store) +# Supysonic database close +def close_db(error): + if hasattr(g, 'database'): + g.database.close() -def teardown_db(exception): - store = getattr(g, 'store', None) - if store: - store.close() +store = LocalProxy(get_db) def create_application(): global app - if not config.check(): - return None + # Check config for mandatory fields + Config().check() - if not os.path.exists(config.get('webapp', 'cache_dir')): - os.makedirs(config.get('webapp', 'cache_dir')) + # Test for the cache directory + if not path.exists(Config().get('webapp', 'cache_dir')): + os.makedirs(Config().get('webapp', 'cache_dir')) + # Flask! app = Flask(__name__) - app.secret_key = '?9huDM\\H' - app.teardown_appcontext(teardown_db) + # Set a secret key for sessions + secret_key = Config().get('base', 'secret_key') + # If secret key is not defined in config, set develop key + if secret_key is None: + app.secret_key = 'd3v3l0p' + else: + app.secret_key = secret_key - if config.get('webapp', 'log_file'): + # Close database connection on teardown + app.teardown_appcontext(close_db) + + # Set loglevel + if Config().get('webapp', 'log_file'): import logging from logging.handlers import TimedRotatingFileHandler - handler = TimedRotatingFileHandler(config.get('webapp', 'log_file'), when = 'midnight') - if config.get('webapp', 'log_level'): + handler = TimedRotatingFileHandler(Config().get('webapp', 'log_file'), when = 'midnight') + if Config().get('webapp', 'log_level'): mapping = { 'DEBUG': logging.DEBUG, 'INFO': logging.INFO, @@ -57,11 +66,11 @@ def create_application(): 'ERROR': logging.ERROR, 'CRTICAL': logging.CRITICAL } - handler.setLevel(mapping.get(config.get('webapp', 'log_level').upper(), logging.NOTSET)) + handler.setLevel(mapping.get(Config().get('webapp', 'log_level').upper(), logging.NOTSET)) app.logger.addHandler(handler) + # Import app sections from supysonic import frontend from supysonic import api return app - From 47237fd8e7cc71911d3c5eb6e39cdf2335d4b02f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Mon, 7 Aug 2017 10:59:21 +0200 Subject: [PATCH 12/25] Add missing return in config.py --- supysonic/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/supysonic/config.py b/supysonic/config.py index 9d61cb7..fc01e33 100644 --- a/supysonic/config.py +++ b/supysonic/config.py @@ -43,6 +43,7 @@ class Config(object): self.config.get('base', 'database_uri') except (NoSectionError, NoOptionError): raise SystemExit('No database URI set') + return True def get(self, section, option): """ From 523903cac58a8b78d454a2a024f2b0f414b2d7ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Mon, 7 Aug 2017 11:04:00 +0200 Subject: [PATCH 13/25] Improved setup script --- MANIFEST.in | 2 ++ setup.py | 65 ++++++++++++++++++++++--------------------- supysonic/__init__.py | 43 +++++++++++++++------------- 3 files changed, 58 insertions(+), 52 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index f19a717..238b63e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,5 @@ include cgi-bin/* include config.sample include README.md +recursive-include supysonic/templates * +recursive-include supysonic/static * diff --git a/setup.py b/setup.py index 54e86fa..e36ccca 100755 --- a/setup.py +++ b/setup.py @@ -1,36 +1,37 @@ -#!/usr/bin/python -# encoding: utf-8 +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 +# +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. +# +# Copyright (C) 2013-2017 Alban 'spl0k' Féron +# 2017 Óscar García Amor +# +# Distributed under terms of the GNU AGPLv3 license. -from distutils.core import setup +import supysonic as project -setup(name='supysonic', - description='Python implementation of the Subsonic server API.', - keywords='subsonic music', - version='0.1', - url='https://github.com/spl0k/supysonic', - license='AGPLv3', - author='Alban Féron', - author_email='alban.feron@gmail.com', - long_description=""" - Supysonic is a Python implementation of the Subsonic server API. +from setuptools import setup +from setuptools import find_packages +from pip.req import parse_requirements +from pip.download import PipSession - Current supported features are: - * browsing (by folders or tags) - * streaming of various audio file formats - * transcoding - * user or random playlists - * cover arts (cover.jpg files in the same folder as music files) - * starred tracks/albums and ratings - * Last.FM scrobbling - """, - packages=['supysonic', 'supysonic.api', 'supysonic.frontend', - 'supysonic.managers'], - scripts=['bin/supysonic-cli', 'bin/supysonic-watcher'], - package_data={'supysonic': [ - 'templates/*.html', - 'static/css/*', - 'static/fonts/*', - 'static/js/*' - ]} - ) +setup( + name=project.NAME, + version=project.VERSION, + description=project.DESCRIPTION, + keywords=project.KEYWORDS, + long_description=project.LONG_DESCRIPTION, + author=project.AUTHOR_NAME, + author_email=project.AUTHOR_EMAIL, + url=project.URL, + license=project.LICENSE, + packages=find_packages(), + install_requires=[str(x.req) for x in + parse_requirements('requirements.txt', session=PipSession())], + scripts=['bin/supysonic-cli', 'bin/supysonic-watcher'], + zip_safe=False, + include_package_data=True + ) diff --git a/supysonic/__init__.py b/supysonic/__init__.py index b10767a..a2bab46 100644 --- a/supysonic/__init__.py +++ b/supysonic/__init__.py @@ -1,25 +1,28 @@ -# coding: utf-8 - +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 +# # This file is part of Supysonic. -# # Supysonic is a Python implementation of the Subsonic server API. -# Copyright (C) 2013-2017 Alban 'spl0k' Féron # -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. +# Copyright (C) 2013-2017 Alban 'spl0k' Féron +# 2017 Óscar García Amor # -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import mimetypes - -def get_mime(ext): - return mimetypes.guess_type('dummy.' + ext, False)[0] or config.get('mimetypes', ext) or 'application/octet-stream' +# Distributed under terms of the GNU AGPLv3 license. +NAME = 'supysonic' +VERSION = '0.2' +DESCRIPTION = 'Python implementation of the Subsonic server API.' +KEYWORDS = 'subsonic music api' +AUTHOR_NAME = 'Alban Féron' +AUTHOR_EMAIL = 'alban.feron@gmail.com' +URL = 'https://github.com/spl0k/supysonic' +LICENSE = 'GNU AGPLv3' +LONG_DESCRIPTION = '''Supysonic is a Python implementation of the Subsonic server API. +Current supported features are: +* browsing (by folders or tags) +* streaming of various audio file formats +* transcoding +* user or random playlists +* cover arts (cover.jpg files in the same folder as music files) +* starred tracks/albums and ratings +* Last.FM scrobbling''' From 04bd88743d5b87941a05eae955dde799737ff0b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Mon, 7 Aug 2017 11:06:46 +0200 Subject: [PATCH 14/25] Fixes shebang line in cgi-bin scripts --- cgi-bin/server.py | 2 +- cgi-bin/supysonic.cgi | 2 +- cgi-bin/supysonic.fcgi | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cgi-bin/server.py b/cgi-bin/server.py index 61043f7..61751a0 100755 --- a/cgi-bin/server.py +++ b/cgi-bin/server.py @@ -1,4 +1,4 @@ -#!/usr/bin/python +#!/usr/bin/env python # coding: utf-8 # This file is part of Supysonic. diff --git a/cgi-bin/supysonic.cgi b/cgi-bin/supysonic.cgi index 9cadacc..e2a7138 100755 --- a/cgi-bin/supysonic.cgi +++ b/cgi-bin/supysonic.cgi @@ -1,4 +1,4 @@ -#!/usr/bin/python +#!/usr/bin/env python # coding: utf-8 # This file is part of Supysonic. diff --git a/cgi-bin/supysonic.fcgi b/cgi-bin/supysonic.fcgi index 81fd551..bec9cfc 100755 --- a/cgi-bin/supysonic.fcgi +++ b/cgi-bin/supysonic.fcgi @@ -1,4 +1,4 @@ -#!/usr/bin/python +#!/usr/bin/env python # coding: utf-8 # This file is part of Supysonic. From a6a37475c26fee308e21a28d9306179b791ecdfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Mon, 7 Aug 2017 11:11:15 +0200 Subject: [PATCH 15/25] Retab and change header of user.py --- supysonic/managers/user.py | 251 ++++++++++++++++++------------------- 1 file changed, 122 insertions(+), 129 deletions(-) diff --git a/supysonic/managers/user.py b/supysonic/managers/user.py index 211dfa1..09a43ec 100644 --- a/supysonic/managers/user.py +++ b/supysonic/managers/user.py @@ -1,24 +1,17 @@ -# coding: utf-8 - +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 +# # This file is part of Supysonic. -# # Supysonic is a Python implementation of the Subsonic server API. -# Copyright (C) 2013 Alban 'spl0k' Féron # -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. +# Copyright (C) 2013-2017 Alban 'spl0k' Féron +# 2017 Óscar García Amor # -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . +# Distributed under terms of the GNU AGPLv3 license. -import string, random, hashlib +import string +import random +import hashlib import uuid from supysonic.db import User, ChatMessage, Playlist @@ -26,140 +19,140 @@ from supysonic.db import StarredFolder, StarredArtist, StarredAlbum, StarredTrac from supysonic.db import RatingFolder, RatingTrack class UserManager: - SUCCESS = 0 - INVALID_ID = 1 - NO_SUCH_USER = 2 - NAME_EXISTS = 3 - WRONG_PASS = 4 + SUCCESS = 0 + INVALID_ID = 1 + NO_SUCH_USER = 2 + NAME_EXISTS = 3 + WRONG_PASS = 4 - @staticmethod - def get(store, uid): - if type(uid) in (str, unicode): - try: - uid = uuid.UUID(uid) - except: - return UserManager.INVALID_ID, None - elif type(uid) is uuid.UUID: - pass - else: - return UserManager.INVALID_ID, None + @staticmethod + def get(store, uid): + if type(uid) in (str, unicode): + try: + uid = uuid.UUID(uid) + except: + return UserManager.INVALID_ID, None + elif type(uid) is uuid.UUID: + pass + else: + return UserManager.INVALID_ID, None - user = store.get(User, uid) - if user is None: - return UserManager.NO_SUCH_USER, None + user = store.get(User, uid) + if user is None: + return UserManager.NO_SUCH_USER, None - return UserManager.SUCCESS, user + return UserManager.SUCCESS, user - @staticmethod - def add(store, name, password, mail, admin): - if store.find(User, User.name == name).one(): - return UserManager.NAME_EXISTS + @staticmethod + def add(store, name, password, mail, admin): + if store.find(User, User.name == name).one(): + return UserManager.NAME_EXISTS - password = UserManager.__decode_password(password) - crypt, salt = UserManager.__encrypt_password(password) + password = UserManager.__decode_password(password) + crypt, salt = UserManager.__encrypt_password(password) - user = User() - user.name = name - user.mail = mail - user.password = crypt - user.salt = salt - user.admin = admin + user = User() + user.name = name + user.mail = mail + user.password = crypt + user.salt = salt + user.admin = admin - store.add(user) - store.commit() + store.add(user) + store.commit() - return UserManager.SUCCESS + return UserManager.SUCCESS - @staticmethod - def delete(store, uid): - status, user = UserManager.get(store, uid) - if status != UserManager.SUCCESS: - return status + @staticmethod + def delete(store, uid): + status, user = UserManager.get(store, uid) + if status != UserManager.SUCCESS: + return status - store.find(StarredFolder, StarredFolder.user_id == uid).remove() - store.find(StarredArtist, StarredArtist.user_id == uid).remove() - store.find(StarredAlbum, StarredAlbum.user_id == uid).remove() - store.find(StarredTrack, StarredTrack.user_id == uid).remove() - store.find(RatingFolder, RatingFolder.user_id == uid).remove() - store.find(RatingTrack, RatingTrack.user_id == uid).remove() - store.find(ChatMessage, ChatMessage.user_id == uid).remove() - for playlist in store.find(Playlist, Playlist.user_id == uid): - playlist.tracks.clear() - store.remove(playlist) + store.find(StarredFolder, StarredFolder.user_id == uid).remove() + store.find(StarredArtist, StarredArtist.user_id == uid).remove() + store.find(StarredAlbum, StarredAlbum.user_id == uid).remove() + store.find(StarredTrack, StarredTrack.user_id == uid).remove() + store.find(RatingFolder, RatingFolder.user_id == uid).remove() + store.find(RatingTrack, RatingTrack.user_id == uid).remove() + store.find(ChatMessage, ChatMessage.user_id == uid).remove() + for playlist in store.find(Playlist, Playlist.user_id == uid): + playlist.tracks.clear() + store.remove(playlist) - store.remove(user) - store.commit() + store.remove(user) + store.commit() - return UserManager.SUCCESS + return UserManager.SUCCESS - @staticmethod - def try_auth(store, name, password): - password = UserManager.__decode_password(password) - user = store.find(User, User.name == name).one() - if not user: - return UserManager.NO_SUCH_USER, None - elif UserManager.__encrypt_password(password, user.salt)[0] != user.password: - return UserManager.WRONG_PASS, None - else: - return UserManager.SUCCESS, user + @staticmethod + def try_auth(store, name, password): + password = UserManager.__decode_password(password) + user = store.find(User, User.name == name).one() + if not user: + return UserManager.NO_SUCH_USER, None + elif UserManager.__encrypt_password(password, user.salt)[0] != user.password: + return UserManager.WRONG_PASS, None + else: + return UserManager.SUCCESS, user - @staticmethod - def change_password(store, uid, old_pass, new_pass): - status, user = UserManager.get(store, uid) - if status != UserManager.SUCCESS: - return status + @staticmethod + def change_password(store, uid, old_pass, new_pass): + status, user = UserManager.get(store, uid) + if status != UserManager.SUCCESS: + return status - old_pass = UserManager.__decode_password(old_pass) - new_pass = UserManager.__decode_password(new_pass) + old_pass = UserManager.__decode_password(old_pass) + new_pass = UserManager.__decode_password(new_pass) - if UserManager.__encrypt_password(old_pass, user.salt)[0] != user.password: - return UserManager.WRONG_PASS + if UserManager.__encrypt_password(old_pass, user.salt)[0] != user.password: + return UserManager.WRONG_PASS - user.password = UserManager.__encrypt_password(new_pass, user.salt)[0] - store.commit() - return UserManager.SUCCESS + user.password = UserManager.__encrypt_password(new_pass, user.salt)[0] + store.commit() + return UserManager.SUCCESS - @staticmethod - def change_password2(store, name, new_pass): - user = store.find(User, User.name == name).one() - if not user: - return UserManager.NO_SUCH_USER + @staticmethod + def change_password2(store, name, new_pass): + user = store.find(User, User.name == name).one() + if not user: + return UserManager.NO_SUCH_USER - new_pass = UserManager.__decode_password(new_pass) - user.password = UserManager.__encrypt_password(new_pass, user.salt)[0] - store.commit() - return UserManager.SUCCESS + new_pass = UserManager.__decode_password(new_pass) + user.password = UserManager.__encrypt_password(new_pass, user.salt)[0] + store.commit() + return UserManager.SUCCESS - @staticmethod - def error_str(err): - if err == UserManager.SUCCESS: - return 'No error' - elif err == UserManager.INVALID_ID: - return 'Invalid user id' - elif err == UserManager.NO_SUCH_USER: - return 'No such user' - elif err == UserManager.NAME_EXISTS: - return 'There is already a user with that name' - elif err == UserManager.WRONG_PASS: - return 'Wrong password' - else: - return 'Unkown error' + @staticmethod + def error_str(err): + if err == UserManager.SUCCESS: + return 'No error' + elif err == UserManager.INVALID_ID: + return 'Invalid user id' + elif err == UserManager.NO_SUCH_USER: + return 'No such user' + elif err == UserManager.NAME_EXISTS: + return 'There is already a user with that name' + elif err == UserManager.WRONG_PASS: + return 'Wrong password' + else: + return 'Unkown error' - @staticmethod - def __encrypt_password(password, salt = None): - if salt is None: - salt = ''.join(random.choice(string.printable.strip()) for i in xrange(6)) - return hashlib.sha1(salt + password).hexdigest(), salt + @staticmethod + def __encrypt_password(password, salt = None): + if salt is None: + salt = ''.join(random.choice(string.printable.strip()) for i in xrange(6)) + return hashlib.sha1(salt + password).hexdigest(), salt - @staticmethod - def __decode_password(password): - if not password.startswith('enc:'): - return password + @staticmethod + def __decode_password(password): + if not password.startswith('enc:'): + return password - enc = password[4:] - ret = '' - while enc: - ret = ret + chr(int(enc[:2], 16)) - enc = enc[2:] - return ret + enc = password[4:] + ret = '' + while enc: + ret = ret + chr(int(enc[:2], 16)) + enc = enc[2:] + return ret From 9818117b4671d275a3fffdd0073f0b3d2c57380b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Mon, 7 Aug 2017 11:31:15 +0200 Subject: [PATCH 16/25] Fixes #67 --- supysonic/managers/user.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/supysonic/managers/user.py b/supysonic/managers/user.py index 09a43ec..d4bd752 100644 --- a/supysonic/managers/user.py +++ b/supysonic/managers/user.py @@ -142,17 +142,14 @@ class UserManager: def __encrypt_password(password, salt = None): if salt is None: salt = ''.join(random.choice(string.printable.strip()) for i in xrange(6)) - return hashlib.sha1(salt + password).hexdigest(), salt + return hashlib.sha1(salt.encode('utf-8') + password.encode('utf-8')).hexdigest(), salt @staticmethod def __decode_password(password): if not password.startswith('enc:'): return password - enc = password[4:] - ret = '' - while enc: - ret = ret + chr(int(enc[:2], 16)) - enc = enc[2:] - return ret - + try: + return binascii.unhexlify(password[4:]) + except: + return password From ee6a67752ec5caea8e439fb2fc72316c897758f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Mon, 7 Aug 2017 11:52:16 +0200 Subject: [PATCH 17/25] Add new secret_key option to config.sample --- config.sample | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/config.sample b/config.sample index 3049c7f..1286a0d 100644 --- a/config.sample +++ b/config.sample @@ -7,6 +7,9 @@ ; Optional, restrict scanner to these extensions ; scanner_extensions = mp3 ogg +; Optional for develop, key for sign the session cookies +; secret_key = verydifficultkeyword + [webapp] ; Optional cache directory cache_dir = /var/supysonic/cache @@ -24,7 +27,7 @@ log_level = INFO [lastfm] ; API and secret key to enable scrobbling. http://www.last.fm/api/accounts -; api_key = +; api_key = ; secret = [transcoding] From d9d90ffacf28043eddc54b777111479eb04f47c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Mon, 7 Aug 2017 13:10:06 +0200 Subject: [PATCH 18/25] Retab and change header to frontend __init__.py --- setup.py | 3 +- supysonic/frontend/__init__.py | 61 +++++++++++++++------------------- tests/__init__.py | 19 +++++++++-- tests/test_api.py | 10 ++++-- tests/test_frontend.py | 10 ++++-- tests/test_manager_folder.py | 10 ++++-- tests/test_manager_user.py | 10 ++++-- 7 files changed, 72 insertions(+), 51 deletions(-) diff --git a/setup.py b/setup.py index e36ccca..a154188 100755 --- a/setup.py +++ b/setup.py @@ -33,5 +33,6 @@ setup( parse_requirements('requirements.txt', session=PipSession())], scripts=['bin/supysonic-cli', 'bin/supysonic-watcher'], zip_safe=False, - include_package_data=True + include_package_data=True, + test_suite="tests.suite" ) diff --git a/supysonic/frontend/__init__.py b/supysonic/frontend/__init__.py index d5e5778..6f6fadb 100644 --- a/supysonic/frontend/__init__.py +++ b/supysonic/frontend/__init__.py @@ -1,22 +1,13 @@ -# coding: utf-8 - +# -*- coding: utf-8 -*- +# vim:fenc=utf-8 +# # This file is part of Supysonic. -# # Supysonic is a Python implementation of the Subsonic server API. -# Copyright (C) 2014 Alban 'spl0k' Féron # -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. +# Copyright (C) 2013-2017 Alban 'spl0k' Féron +# 2017 Óscar García Amor # -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . +# Distributed under terms of the GNU AGPLv3 license. from flask import session from supysonic.web import app, store @@ -27,32 +18,32 @@ app.add_template_filter(str) @app.before_request def login_check(): - if request.path.startswith('/rest/'): - return + if request.path.startswith('/rest/'): + return - if request.path.startswith('/static/'): - return + if request.path.startswith('/static/'): + return - if request.endpoint != 'login': - should_login = False - if not session.get('userid'): - should_login = True - elif UserManager.get(store, session.get('userid'))[0] != UserManager.SUCCESS: - session.clear() - should_login = True + if request.endpoint != 'login': + should_login = False + if not session.get('userid'): + should_login = True + elif UserManager.get(store, session.get('userid'))[0] != UserManager.SUCCESS: + session.clear() + should_login = True - if should_login: - flash('Please login') - return redirect(url_for('login', returnUrl = request.script_root + request.url[len(request.url_root)-1:])) + if should_login: + flash('Please login') + return redirect(url_for('login', returnUrl = request.script_root + request.url[len(request.url_root)-1:])) @app.route('/') def index(): - stats = { - 'artists': store.find(Artist).count(), - 'albums': store.find(Album).count(), - 'tracks': store.find(Track).count() - } - return render_template('home.html', stats = stats, admin = UserManager.get(store, session.get('userid'))[1].admin) + stats = { + 'artists': store.find(Artist).count(), + 'albums': store.find(Album).count(), + 'tracks': store.find(Track).count() + } + return render_template('home.html', stats = stats, admin = UserManager.get(store, session.get('userid'))[1].admin) from .user import * from .folder import * diff --git a/tests/__init__.py b/tests/__init__.py index d3e8bcd..bd00f37 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,12 +1,25 @@ -#! /usr/bin/env python # -*- coding: utf-8 -*- # vim:fenc=utf-8 # -# Copyright © 2017 Óscar García Amor +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. # -# Distributed under terms of the GNU GPLv3 license. +# Copyright (C) 2013-2017 Alban 'spl0k' Féron +# 2017 Óscar García Amor +# +# Distributed under terms of the GNU AGPLv3 license. + +import unittest from .test_manager_folder import FolderManagerTestCase from .test_manager_user import UserManagerTestCase from .test_api import ApiTestCase from .test_frontend import FrontendTestCase + +def suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(FolderManagerTestCase)) + suite.addTest(unittest.makeSuite(UserManagerTestCase)) + suite.addTest(unittest.makeSuite(ApiTestCase)) + suite.addTest(unittest.makeSuite(FrontendTestCase)) + return suite diff --git a/tests/test_api.py b/tests/test_api.py index 52c80cf..5afd8f0 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,10 +1,14 @@ -#! /usr/bin/env python +#!/usr/bin/env python # -*- coding: utf-8 -*- # vim:fenc=utf-8 # -# Copyright © 2017 Óscar García Amor +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. # -# Distributed under terms of the GNU GPLv3 license. +# Copyright (C) 2013-2017 Alban 'spl0k' Féron +# 2017 Óscar García Amor +# +# Distributed under terms of the GNU AGPLv3 license. from supysonic import db from supysonic.managers.user import UserManager diff --git a/tests/test_frontend.py b/tests/test_frontend.py index d3a77c1..f7d8d26 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -1,10 +1,14 @@ -#! /usr/bin/env python +#!/usr/bin/env python # -*- coding: utf-8 -*- # vim:fenc=utf-8 # -# Copyright © 2017 Óscar García Amor +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. # -# Distributed under terms of the GNU GPLv3 license. +# Copyright (C) 2013-2017 Alban 'spl0k' Féron +# 2017 Óscar García Amor +# +# Distributed under terms of the GNU AGPLv3 license. from supysonic import db from supysonic.managers.user import UserManager diff --git a/tests/test_manager_folder.py b/tests/test_manager_folder.py index bfd8d66..9cbcea3 100644 --- a/tests/test_manager_folder.py +++ b/tests/test_manager_folder.py @@ -1,10 +1,14 @@ -#! /usr/bin/env python +#!/usr/bin/env python # -*- coding: utf-8 -*- # vim:fenc=utf-8 # -# Copyright © 2017 Óscar García Amor +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. # -# Distributed under terms of the GNU GPLv3 license. +# Copyright (C) 2013-2017 Alban 'spl0k' Féron +# 2017 Óscar García Amor +# +# Distributed under terms of the GNU AGPLv3 license. from supysonic import db from supysonic.managers.folder import FolderManager diff --git a/tests/test_manager_user.py b/tests/test_manager_user.py index 38ee23c..3194ddd 100644 --- a/tests/test_manager_user.py +++ b/tests/test_manager_user.py @@ -1,10 +1,14 @@ -#! /usr/bin/env python +#!/usr/bin/env python # -*- coding: utf-8 -*- # vim:fenc=utf-8 # -# Copyright © 2017 Óscar García Amor +# This file is part of Supysonic. +# Supysonic is a Python implementation of the Subsonic server API. # -# Distributed under terms of the GNU GPLv3 license. +# Copyright (C) 2013-2017 Alban 'spl0k' Féron +# 2017 Óscar García Amor +# +# Distributed under terms of the GNU AGPLv3 license. from supysonic import db from supysonic.managers.user import UserManager From 81c356355b1e02ced8e69429ab759a1bb900cf81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Mon, 7 Aug 2017 13:14:03 +0200 Subject: [PATCH 19/25] Check that username match with UUID in login --- supysonic/frontend/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/supysonic/frontend/__init__.py b/supysonic/frontend/__init__.py index 6f6fadb..c004021 100644 --- a/supysonic/frontend/__init__.py +++ b/supysonic/frontend/__init__.py @@ -31,6 +31,9 @@ def login_check(): elif UserManager.get(store, session.get('userid'))[0] != UserManager.SUCCESS: session.clear() should_login = True + elif UserManager.get(store, session.get('userid'))[1].name != session.get('username'): + session.clear() + should_login = True if should_login: flash('Please login') @@ -48,4 +51,3 @@ def index(): from .user import * from .folder import * from .playlist import * - From a2611cb70077f4f894900c27c3249f6130abf128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Mon, 7 Aug 2017 13:19:53 +0200 Subject: [PATCH 20/25] Update .travis.yml to use the new setup.py --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index a338343..759ca25 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ language: python python: - "2.7" -install: "pip install -r requirements.txt" -script: "python -m unittest -v tests" +install: "python setup.py install" +script: "python setup.py test" From a5afece2d7c3d53d9c50aae77afb9f0dd2015ebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Mon, 7 Aug 2017 14:07:52 +0200 Subject: [PATCH 21/25] Add missing import --- supysonic/managers/user.py | 1 + 1 file changed, 1 insertion(+) diff --git a/supysonic/managers/user.py b/supysonic/managers/user.py index d4bd752..27c8adb 100644 --- a/supysonic/managers/user.py +++ b/supysonic/managers/user.py @@ -9,6 +9,7 @@ # # Distributed under terms of the GNU AGPLv3 license. +import binascii import string import random import hashlib From 5f011ed3e476e313f971d0890b1cc975d3bd5260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Mon, 7 Aug 2017 14:32:08 +0200 Subject: [PATCH 22/25] Support UTF-8 passwords with old enc: --- supysonic/managers/user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supysonic/managers/user.py b/supysonic/managers/user.py index 27c8adb..1840520 100644 --- a/supysonic/managers/user.py +++ b/supysonic/managers/user.py @@ -151,6 +151,6 @@ class UserManager: return password try: - return binascii.unhexlify(password[4:]) + return binascii.unhexlify(password[4:]).decode('utf-8') except: return password From 09ce5ae455a2ddd4e84a6e560692b98de5fb45c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Mon, 7 Aug 2017 14:37:05 +0200 Subject: [PATCH 23/25] Update tests to check UTF-8 passwords --- tests/test_api.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_api.py b/tests/test_api.py index 5afd8f0..cffe97e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -60,6 +60,12 @@ class ApiTestCase(unittest.TestCase): # POST user request rv = self.app.post('/rest/ping.view', data=dict(u='alice', p='alice', c='test')) self.assertIn('status="ok"', rv.data) + # GET user request with old enc: + rv = self.app.get('/rest/ping.view?u=alice&p=enc:616c696365&c=test') + self.assertIn('status="ok"', rv.data) + # POST user request with old enc: + rv = self.app.post('/rest/ping.view', data=dict(u='alice', p='enc:616c696365', c='test')) + self.assertIn('status="ok"', rv.data) # GET user request with bad password rv = self.app.get('/rest/ping.view?u=alice&p=bad&c=test') self.assertIn('status="failed"', rv.data) @@ -268,6 +274,24 @@ class ApiTestCase(unittest.TestCase): # POST change non-existent user password rv = self.app.post('/rest/changePassword.view', data=dict(u='alice', p='alice', c='test', username='nonexistent', password='nonexistent')) self.assertIn('message="No such user"', rv.data) + # GET non-admin change own password using extended utf-8 characters + rv = self.app.get('/rest/changePassword.view?u=bob&p=bob&c=test&username=bob&password=новыйпароль') + self.assertIn('status="ok"', rv.data) + # POST non-admin change own password using extended utf-8 characters + rv = self.app.post('/rest/changePassword.view', data=dict(u='bob', p='новыйпароль', c='test', username='bob', password='bob')) + self.assertIn('status="ok"', rv.data) + # GET non-admin change own password using extended utf-8 characters with old enc: + rv = self.app.get('/rest/changePassword.view?u=bob&p=enc:626f62&c=test&username=bob&password=новыйпароль') + self.assertIn('status="ok"', rv.data) + # POST non-admin change own password using extended utf-8 characters with old enc: + rv = self.app.post('/rest/changePassword.view', data=dict(u='bob', p='enc:d0bdd0bed0b2d18bd0b9d0bfd0b0d180d0bed0bbd18c', c='test', username='bob', password='bob')) + self.assertIn('status="ok"', rv.data) + # GET non-admin change own password using enc: in password + rv = self.app.get('/rest/changePassword.view?u=bob&p=bob&c=test&username=bob&password=enc:test') + self.assertIn('status="ok"', rv.data) + # POST non-admin change own password using enc: in password + rv = self.app.post('/rest/changePassword.view', data=dict(u='bob', p='enc:test', c='test', username='bob', password='bob')) + self.assertIn('status="ok"', rv.data) if __name__ == '__main__': unittest.main() From a1619dd96a9179c3edaa6809de5d3c3dd423e3c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Tue, 8 Aug 2017 10:37:22 +0200 Subject: [PATCH 24/25] Improve config.py to read file only in check --- .gitignore | 70 +++++++++++++++++++++++++-- bin/supysonic-cli | 6 +-- supysonic/api/media.py | 14 +++--- supysonic/config.py | 96 +++++++++++++++++++------------------- supysonic/db.py | 4 +- supysonic/frontend/user.py | 6 +-- supysonic/lastfm.py | 6 +-- supysonic/scanner.py | 6 +-- supysonic/watcher.py | 18 +++---- supysonic/web.py | 20 ++++---- 10 files changed, 153 insertions(+), 93 deletions(-) diff --git a/.gitignore b/.gitignore index 57c56ef..5c99f15 100755 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,68 @@ -*.pyc -.*.sw[a-z] -*~ +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ build/ +develop-eggs/ dist/ -MANIFEST +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# ---> Vim +[._]*.s[a-w][a-z] +[._]s[a-w][a-z] +*.un~ +Session.vim +.netrwhist +*~ + diff --git a/bin/supysonic-cli b/bin/supysonic-cli index c6d70bc..2aba005 100755 --- a/bin/supysonic-cli +++ b/bin/supysonic-cli @@ -20,7 +20,7 @@ # along with this program. If not, see . import sys, cmd, argparse, getpass, time -from supysonic.config import Config +from supysonic import config from supysonic.db import get_store, Folder, User from supysonic.managers.folder import FolderManager @@ -225,10 +225,10 @@ class CLI(cmd.Cmd): print "Successfully changed '{}' password".format(name) if __name__ == "__main__": - if not Config().check(): + if not config.check(): sys.exit(1) - cli = CLI(get_store(Config().get('base', 'database_uri'))) + cli = CLI(get_store(config.get('base', 'database_uri'))) if len(sys.argv) > 1: cli.onecmd(' '.join(sys.argv[1:])) else: diff --git a/supysonic/api/media.py b/supysonic/api/media.py index b880875..87baf65 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -27,7 +27,7 @@ import codecs from xml.etree import ElementTree from supysonic import scanner -from supysonic.config import Config +from supysonic import config from supysonic.web import app, store from supysonic.db import Track, Album, Artist, Folder, User, ClientPrefs, now from . import get_entity @@ -71,14 +71,14 @@ def stream_media(): if format and format != 'raw' and format != src_suffix: dst_suffix = format - dst_mimetype = Config().get_mime(dst_suffix) + dst_mimetype = config.get_mime(dst_suffix) if format != 'raw' and (dst_suffix != src_suffix or dst_bitrate != res.bitrate): - transcoder = Config().get('transcoding', 'transcoder_{}_{}'.format(src_suffix, dst_suffix)) - decoder = Config().get('transcoding', 'decoder_' + src_suffix) or Config().get('transcoding', 'decoder') - encoder = Config().get('transcoding', 'encoder_' + dst_suffix) or Config().get('transcoding', 'encoder') + transcoder = config.get('transcoding', 'transcoder_{}_{}'.format(src_suffix, dst_suffix)) + decoder = config.get('transcoding', 'decoder_' + src_suffix) or config.get('transcoding', 'decoder') + encoder = config.get('transcoding', 'encoder_' + dst_suffix) or config.get('transcoding', 'encoder') if not transcoder and (not decoder or not encoder): - transcoder = Config().get('transcoding', 'transcoder') + transcoder = config.get('transcoding', 'transcoder') if not transcoder: message = 'No way to transcode from {} to {}'.format(src_suffix, dst_suffix) app.logger.info(message) @@ -154,7 +154,7 @@ def cover_art(): if size > im.size[0] and size > im.size[1]: return send_file(os.path.join(res.path, 'cover.jpg')) - size_path = os.path.join(Config().get('webapp', 'cache_dir'), str(size)) + size_path = os.path.join(config.get('webapp', 'cache_dir'), str(size)) path = os.path.join(size_path, str(res.id)) if os.path.exists(path): return send_file(path) diff --git a/supysonic/config.py b/supysonic/config.py index fc01e33..01013e6 100644 --- a/supysonic/config.py +++ b/supysonic/config.py @@ -15,59 +15,57 @@ import mimetypes import os import tempfile -class Config(object): +# Seek for standard locations +config_file = [ + 'supysonic.conf', + os.path.expanduser('~/.config/supysonic/supysonic.conf'), + os.path.expanduser('~/.supysonic'), + '/etc/supysonic' + ] + +config = ConfigParser({ 'cache_dir': os.path.join(tempfile.gettempdir(), 'supysonic') }) + + +def check(): """ - Config object to work with config file + Checks the config file and mandatory fields """ - def __init__(self): - # Seek for standard locations - config_file = [ - 'supysonic.conf', - os.path.expanduser('~/.config/supysonic/supysonic.conf'), - os.path.expanduser('~/.supysonic'), - '/etc/supysonic' - ] - self.config = ConfigParser({ 'cache_dir': os.path.join(tempfile.gettempdir(), 'supysonic') }) - # Try read config file or raise error - try: - self.config.read(config_file) - except Exception as e: - err = 'Config file is corrupted.\n{0}'.format(e) - raise SystemExit(err) + try: + config.read(config_file) + except Exception as e: + err = 'Config file is corrupted.\n{0}'.format(e) + raise SystemExit(err) - def check(self): - """ - Checks the config for mandatory fields - """ - try: - self.config.get('base', 'database_uri') - except (NoSectionError, NoOptionError): - raise SystemExit('No database URI set') - return True + try: + config.get('base', 'database_uri') + except (NoSectionError, NoOptionError): + raise SystemExit('No database URI set') - def get(self, section, option): - """ - Returns a config option value from config file + return True - :param section: section where the option is stored - :param option: option name - :return: a config option value - :rtype: string - """ - try: - return self.config.get(section, option) - except (NoSectionError, NoOptionError): - return None +def get(section, option): + """ + Returns a config option value from config file - def get_mime(self, extension): - """ - Returns mimetype of an extension based on config file + :param section: section where the option is stored + :param option: option name + :return: a config option value + :rtype: string + """ + try: + return config.get(section, option) + except (NoSectionError, NoOptionError): + return None - :param extension: extension string - :return: mimetype - :rtype: string - """ - guessed_mime = mimetypes.guess_type('dummy.' + extension, False)[0] - config_mime = self.get('mimetypes', extension) - default_mime = 'application/octet-stream' - return guessed_mime or config_mime or default_mime +def get_mime(extension): + """ + Returns mimetype of an extension based on config file + + :param extension: extension string + :return: mimetype + :rtype: string + """ + guessed_mime = mimetypes.guess_type('dummy.' + extension, False)[0] + config_mime = get('mimetypes', extension) + default_mime = 'application/octet-stream' + return guessed_mime or config_mime or default_mime diff --git a/supysonic/db.py b/supysonic/db.py index 59340fd..4daad71 100644 --- a/supysonic/db.py +++ b/supysonic/db.py @@ -27,7 +27,7 @@ from storm.variables import Variable import uuid, datetime, time import os.path -from supysonic.config import Config +from supysonic import config def now(): return datetime.datetime.now().replace(microsecond = 0) @@ -213,7 +213,7 @@ class Track(object): if prefs and prefs.format and prefs.format != self.suffix(): info['transcodedSuffix'] = prefs.format - info['transcodedContentType'] = Config().get_mime(prefs.format) + info['transcodedContentType'] = config.get_mime(prefs.format) return info diff --git a/supysonic/frontend/user.py b/supysonic/frontend/user.py index a427735..86d237d 100644 --- a/supysonic/frontend/user.py +++ b/supysonic/frontend/user.py @@ -24,7 +24,7 @@ from supysonic.web import app, store from supysonic.managers.user import UserManager from supysonic.db import User, ClientPrefs import uuid, csv -from supysonic.config import Config +from supysonic import config from supysonic.lastfm import LastFm @app.before_request @@ -43,12 +43,12 @@ def user_index(): def user_profile(uid): if uid == 'me': prefs = store.find(ClientPrefs, ClientPrefs.user_id == uuid.UUID(session.get('userid'))) - return render_template('profile.html', user = UserManager.get(store, session.get('userid'))[1], api_key = Config().get('lastfm', 'api_key'), clients = prefs, admin = UserManager.get(store, session.get('userid'))[1].admin) + return render_template('profile.html', user = UserManager.get(store, session.get('userid'))[1], api_key = config.get('lastfm', 'api_key'), clients = prefs, admin = UserManager.get(store, session.get('userid'))[1].admin) else: if not UserManager.get(store, session.get('userid'))[1].admin or not UserManager.get(store, uid)[0] is UserManager.SUCCESS: return redirect(url_for('index')) prefs = store.find(ClientPrefs, ClientPrefs.user_id == uuid.UUID(uid)) - return render_template('profile.html', user = UserManager.get(store, uid)[1], api_key = Config().get('lastfm', 'api_key'), clients = prefs, admin = UserManager.get(store, session.get('userid'))[1].admin) + return render_template('profile.html', user = UserManager.get(store, uid)[1], api_key = config.get('lastfm', 'api_key'), clients = prefs, admin = UserManager.get(store, session.get('userid'))[1].admin) @app.route('/user/', methods = [ 'POST' ]) def update_clients(uid): diff --git a/supysonic/lastfm.py b/supysonic/lastfm.py index 0d90da3..5a799f3 100644 --- a/supysonic/lastfm.py +++ b/supysonic/lastfm.py @@ -19,13 +19,13 @@ # along with this program. If not, see . import requests, hashlib -from supysonic.config import Config +from supysonic import config class LastFm: def __init__(self, user, logger): self.__user = user - self.__api_key = Config().get('lastfm', 'api_key') - self.__api_secret = Config().get('lastfm', 'secret') + self.__api_key = config.get('lastfm', 'api_key') + self.__api_secret = config.get('lastfm', 'secret') self.__enabled = self.__api_key is not None and self.__api_secret is not None self.__logger = logger diff --git a/supysonic/scanner.py b/supysonic/scanner.py index c5589d8..30d85f3 100644 --- a/supysonic/scanner.py +++ b/supysonic/scanner.py @@ -25,7 +25,7 @@ import mutagen from storm.expr import ComparableExpr, compile, Like from storm.exceptions import NotSupportedError -from supysonic.config import Config +from supysonic import config from supysonic.db import Folder, Artist, Album, Track, User, PlaylistTrack from supysonic.db import StarredFolder, StarredArtist, StarredAlbum, StarredTrack from supysonic.db import RatingFolder, RatingTrack @@ -63,7 +63,7 @@ class Scanner: self.__deleted_albums = 0 self.__deleted_tracks = 0 - extensions = Config().get('base', 'scanner_extensions') + extensions = config.get('base', 'scanner_extensions') self.__extensions = map(str.lower, extensions.split()) if extensions else None self.__folders_to_check = set() @@ -166,7 +166,7 @@ class Scanner: tr.duration = int(tag.info.length) tr.bitrate = (tag.info.bitrate if hasattr(tag.info, 'bitrate') else int(os.path.getsize(path) * 8 / tag.info.length)) / 1000 - tr.content_type = Config().get_mime(os.path.splitext(path)[1][1:]) + tr.content_type = config.get_mime(os.path.splitext(path)[1][1:]) tr.last_modification = os.path.getmtime(path) tralbum = self.__find_album(albumartist, album) diff --git a/supysonic/watcher.py b/supysonic/watcher.py index 7a43d71..2e43109 100644 --- a/supysonic/watcher.py +++ b/supysonic/watcher.py @@ -27,7 +27,7 @@ from watchdog.observers import Observer from watchdog.events import PatternMatchingEventHandler from supysonic import db -from supysonic.config import Config +from supysonic import config from supysonic.scanner import Scanner OP_SCAN = 1 @@ -36,7 +36,7 @@ OP_MOVE = 4 class SupysonicWatcherEventHandler(PatternMatchingEventHandler): def __init__(self, queue, logger): - extensions = Config().get('base', 'scanner_extensions') + extensions = config.get('base', 'scanner_extensions') patterns = map(lambda e: "*." + e.lower(), extensions.split()) if extensions else None super(SupysonicWatcherEventHandler, self).__init__(patterns = patterns, ignore_directories = True) @@ -133,7 +133,7 @@ class ScannerProcessingQueue(Thread): continue self.__logger.debug("Instantiating scanner") - store = db.get_store(Config().get('base', 'database_uri')) + store = db.get_store(config.get('base', 'database_uri')) scanner = Scanner(store) item = self.__next_item() @@ -201,17 +201,17 @@ class ScannerProcessingQueue(Thread): class SupysonicWatcher(object): def run(self): - if not Config().check(): + if not config.check(): return logger = logging.getLogger(__name__) - if Config().get('daemon', 'log_file'): - log_handler = TimedRotatingFileHandler(Config().get('daemon', 'log_file'), when = 'midnight') + if config.get('daemon', 'log_file'): + log_handler = TimedRotatingFileHandler(config.get('daemon', 'log_file'), when = 'midnight') else: log_handler = logging.NullHandler() log_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) logger.addHandler(log_handler) - if Config().get('daemon', 'log_level'): + if config.get('daemon', 'log_level'): mapping = { 'DEBUG': logging.DEBUG, 'INFO': logging.INFO, @@ -219,9 +219,9 @@ class SupysonicWatcher(object): 'ERROR': logging.ERROR, 'CRTICAL': logging.CRITICAL } - logger.setLevel(mapping.get(Config().get('daemon', 'log_level').upper(), logging.NOTSET)) + logger.setLevel(mapping.get(config.get('daemon', 'log_level').upper(), logging.NOTSET)) - store = db.get_store(Config().get('base', 'database_uri')) + store = db.get_store(config.get('base', 'database_uri')) folders = store.find(db.Folder, db.Folder.root == True) if not folders.count(): diff --git a/supysonic/web.py b/supysonic/web.py index eb1be93..1260882 100644 --- a/supysonic/web.py +++ b/supysonic/web.py @@ -13,13 +13,13 @@ from flask import Flask, g from os import makedirs, path from werkzeug.local import LocalProxy -from supysonic.config import Config +from supysonic import config from supysonic.db import get_store # Supysonic database open def get_db(): if not hasattr(g, 'database'): - g.database = get_store(Config().get('base', 'database_uri')) + g.database = get_store(config.get('base', 'database_uri')) return g.database # Supysonic database close @@ -33,17 +33,17 @@ def create_application(): global app # Check config for mandatory fields - Config().check() + config.check() # Test for the cache directory - if not path.exists(Config().get('webapp', 'cache_dir')): - os.makedirs(Config().get('webapp', 'cache_dir')) + if not path.exists(config.get('webapp', 'cache_dir')): + os.makedirs(config.get('webapp', 'cache_dir')) # Flask! app = Flask(__name__) # Set a secret key for sessions - secret_key = Config().get('base', 'secret_key') + secret_key = config.get('base', 'secret_key') # If secret key is not defined in config, set develop key if secret_key is None: app.secret_key = 'd3v3l0p' @@ -54,11 +54,11 @@ def create_application(): app.teardown_appcontext(close_db) # Set loglevel - if Config().get('webapp', 'log_file'): + if config.get('webapp', 'log_file'): import logging from logging.handlers import TimedRotatingFileHandler - handler = TimedRotatingFileHandler(Config().get('webapp', 'log_file'), when = 'midnight') - if Config().get('webapp', 'log_level'): + handler = TimedRotatingFileHandler(config.get('webapp', 'log_file'), when = 'midnight') + if config.get('webapp', 'log_level'): mapping = { 'DEBUG': logging.DEBUG, 'INFO': logging.INFO, @@ -66,7 +66,7 @@ def create_application(): 'ERROR': logging.ERROR, 'CRTICAL': logging.CRITICAL } - handler.setLevel(mapping.get(Config().get('webapp', 'log_level').upper(), logging.NOTSET)) + handler.setLevel(mapping.get(config.get('webapp', 'log_level').upper(), logging.NOTSET)) app.logger.addHandler(handler) # Import app sections From 7fe88d46702aa1a9ae75f11eb60be29ec1fbf70c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=93scar=20Garc=C3=ADa=20Amor?= Date: Tue, 8 Aug 2017 10:56:42 +0200 Subject: [PATCH 25/25] Set some imports in one line --- supysonic/api/media.py | 3 +-- supysonic/watcher.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/supysonic/api/media.py b/supysonic/api/media.py index 87baf65..efb59b1 100644 --- a/supysonic/api/media.py +++ b/supysonic/api/media.py @@ -26,8 +26,7 @@ import subprocess import codecs from xml.etree import ElementTree -from supysonic import scanner -from supysonic import config +from supysonic import config, scanner from supysonic.web import app, store from supysonic.db import Track, Album, Artist, Folder, User, ClientPrefs, now from . import get_entity diff --git a/supysonic/watcher.py b/supysonic/watcher.py index 2e43109..e502ff7 100644 --- a/supysonic/watcher.py +++ b/supysonic/watcher.py @@ -26,8 +26,7 @@ from logging.handlers import TimedRotatingFileHandler from watchdog.observers import Observer from watchdog.events import PatternMatchingEventHandler -from supysonic import db -from supysonic import config +from supysonic import config, db from supysonic.scanner import Scanner OP_SCAN = 1