plone.resourceeditor/master: Split the error message for the move API endpoint into two.
Oshane Bailey <jenkins-z4DKO/[email protected]>
| Newsgroups | gmane.comp.web.zope.plone.cvs |
|---|---|
| Message-ID | <[email protected]> |
Repository: plone.resourceeditor Branch: refs/heads/master Date: 2017-06-22T12:30:23-05:00 Author: Oshane Bailey (b4oshany) <[email protected]> Commit: https://github.com/plone/plone.resourceeditor/commit/e10ffedaf3e0cf83a1a30ee4b8ccc5851d923896 Split the error message for the move API endpoint into two. One is for the parent folder and the other is for the distination folder Files changed: A plone/resourceeditor/tests/__init__.py A plone/resourceeditor/tests/test_file_manager.py A plone/resourceeditor/tests/test_file_manager_action.py M CHANGES.rst M plone/resourceeditor/browser.py M plone/resourceeditor/testing.py D plone/resourceeditor/tests.py diff --git a/CHANGES.rst b/CHANGES.rst index 283b009..7cfe5d7 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -12,13 +12,22 @@ New: - *add item here* - Add the move API endpoint to the FileManagerActions class + [b4oshany] +- Add test cases for FileManagerAction + [b4oshany] Fixes: - Remove unittest2 dependency [kakshay21] +- Split the error message for the move API endpoint into two. One + is for the parent folder and the other is for the distination folder + [b4oshany] +- Fix Jenkins flake8 errors +[b4oshany]: https://github.com/b4oshany + 2.0.5 (2016-03-31) ------------------ diff --git a/plone/resourceeditor/browser.py b/plone/resourceeditor/browser.py index 8d7f511..4445ba6 100644 --- a/plone/resourceeditor/browser.py +++ b/plone/resourceeditor/browser.py @@ -36,6 +36,7 @@ def authorize(context, request): if authenticator is not None and not authenticator.verify(): raise Unauthorized + invalidFilenameChars = frozenset('\/:*?"<>|') @@ -82,7 +83,7 @@ def getFile(self, path): return json.dumps(result) else: data = self.context.openFile(path) - if hasattr(data, 'read'): + try: data = data.read() result['contents'] = str(data) @@ -96,6 +97,8 @@ def getFile(self, path): info = self.getInfo(obj) result['info'] = self.previewTemplate(info=info) return json.dumps(result) + except AttributeError: + return None def normalizePath(self, path): if path.startswith('/'): @@ -390,32 +393,48 @@ def move(self, path, directory): code = 0 error = '' + newCanonicalPath = '{0}/{1}'.format(newParentPath, filename) try: parent = self.getObject(parentPath) - target = self.getObject(newParentPath) except KeyError: error = translate(_(u'filemanager_invalid_parent', default=u'Parent folder not found.'), context=self.request) code = 1 - else: - if filename not in parent: - error = translate(_(u'filemanager_error_file_not_found', - default=u'File not found.'), - context=self.request) - code = 1 - elif filename in target: - error = translate(_(u'filemanager_error_file_exists', - default=u'File already exists.'), - context=self.request) - code = 1 - else: - obj = parent[filename] - del parent[filename] - target[filename] = obj + return { + 'code': code, + 'error': error, + 'newPath': self.normalizeReturnPath(newCanonicalPath), + } - newCanonicalPath = '{0}/{1}'.format(newParentPath, filename) + try: + target = self.getObject(newParentPath) + except KeyError: + error = translate(_(u'filemanager_error_folder_exists', + default=u'Destination folder not found.'), + context=self.request) + code = 1 + return { + 'code': code, + 'error': error, + 'newPath': self.normalizeReturnPath(newCanonicalPath), + } + + if filename not in parent: + error = translate(_(u'filemanager_error_file_not_found', + default=u'File not found.'), + context=self.request) + code = 1 + elif filename in target: + error = translate(_(u'filemanager_error_file_exists', + default=u'File already exists.'), + context=self.request) + code = 1 + else: + obj = parent[filename] + del parent[filename] + target[filename] = obj return { 'code': code, @@ -423,8 +442,7 @@ def move(self, path, directory): 'newPath': self.normalizeReturnPath(newCanonicalPath), } - def __call__(self): - action = self.request.get('action') + def do_action(self, action): if action == 'dataTree': def getDirectory(folder, relpath=''): @@ -482,6 +500,10 @@ def getDirectory(folder, relpath=''): des_path = self.request.get('destination', '') return self.move(src_path, des_path) + def __call__(self): + action = self.request.get('action') + return self.do_action(action) + class FileManager(BrowserView): """Render the file manager and support its AJAX requests. @@ -518,6 +540,68 @@ def pattern_options(self): ) }) + def mode_selector(self, form): + # AJAX methods called by the file manager + mode = form['mode'] + + if mode in self.protectedActions: + authorize(self.context, self.request) + + response = {'error:': 'Unknown request', 'code': -1} + textareaWrap = False + + if mode == u'getfolder': + response = self.getFolder( + path=urllib.unquote(form['path']), + getSizes=form.get('getsizes', 'false') == 'true' + ) + elif mode == u'getinfo': + response = self.getInfo( + path=urllib.unquote(form['path']), + getSize=form.get('getsize', 'false') == 'true' + ) + elif mode == u'addfolder': + response = self.addFolder( + path=urllib.unquote(form['path']), + name=urllib.unquote(form['name']) + ) + elif mode == u'add': + textareaWrap = True + response = self.add( + path=urllib.unquote(form['currentpath']), + newfile=form['newfile'], + replacepath=form.get('replacepath', None) + ) + elif mode == u'addnew': + response = self.addNew( + path=urllib.unquote(form['path']), + name=urllib.unquote(form['name']) + ) + elif mode == u'rename': + response = self.rename( + path=urllib.unquote(form['old']), + newName=urllib.unquote(form['new']) + ) + elif mode == u'delete': + response = self.delete( + path=urllib.unquote(form['path']) + ) + elif mode == 'move': + response = self.move( + path=urllib.unquote(form['path']), + directory=urllib.unquote(form['directory']) + ) + elif mode == u'download': + return self.download( + path=urllib.unquote(form['path']) + ) + if textareaWrap: + self.request.response.setHeader('Content-Type', 'text/html') + return '<textarea>{0}</textarea>'.format(json.dumps(response)) + self.request.response.setHeader('Content-Type', + 'application/json') + return json.dumps(response) + def __call__(self): # make sure theme is disable for these requests self.request.response.setHeader('X-Theme-Disabled', 'True') @@ -528,66 +612,7 @@ def __call__(self): # AJAX methods called by the file manager if 'mode' in form: - mode = form['mode'] - - if mode in self.protectedActions: - authorize(self.context, self.request) - - response = {'error:': 'Unknown request', 'code': -1} - textareaWrap = False - - if mode == u'getfolder': - response = self.getFolder( - path=urllib.unquote(form['path']), - getSizes=form.get('getsizes', 'false') == 'true' - ) - elif mode == u'getinfo': - response = self.getInfo( - path=urllib.unquote(form['path']), - getSize=form.get('getsize', 'false') == 'true' - ) - elif mode == u'addfolder': - response = self.addFolder( - path=urllib.unquote(form['path']), - name=urllib.unquote(form['name']) - ) - elif mode == u'add': - textareaWrap = True - response = self.add( - path=urllib.unquote(form['currentpath']), - newfile=form['newfile'], - replacepath=form.get('replacepath', None) - ) - elif mode == u'addnew': - response = self.addNew( - path=urllib.unquote(form['path']), - name=urllib.unquote(form['name']) - ) - elif mode == u'rename': - response = self.rename( - path=urllib.unquote(form['old']), - newName=urllib.unquote(form['new']) - ) - elif mode == u'delete': - response = self.delete( - path=urllib.unquote(form['path']) - ) - elif mode == 'move': - response = self.move( - path=urllib.unquote(form['path']), - directory=urllib.unquote(form['directory']) - ) - elif mode == u'download': - return self.download( - path=urllib.unquote(form['path']) - ) - if textareaWrap: - self.request.response.setHeader('Content-Type', 'text/html') - return '<textarea>{0}</textarea>'.format(json.dumps(response)) - else: - self.request.response.setHeader('Content-Type', - 'application/json') - return json.dumps(response) + return self.mode_selector(form) # Rendering the view else: @@ -1011,32 +1036,48 @@ def move(self, path, directory): code = 0 error = '' + newCanonicalPath = '{0}/{1}'.format(newParentPath, filename) try: parent = self.getObject(parentPath) - target = self.getObject(newParentPath) except KeyError: error = translate(_(u'filemanager_invalid_parent', default=u'Parent folder not found.'), context=self.request) code = 1 - else: - if filename not in parent: - error = translate(_(u'filemanager_error_file_not_found', - default=u'File not found.'), - context=self.request) - code = 1 - elif filename in target: - error = translate(_(u'filemanager_error_file_exists', - default=u'File already exists.'), - context=self.request) - code = 1 - else: - obj = parent[filename] - del parent[filename] - target[filename] = obj + return { + 'code': code, + 'error': error, + 'newPath': self.normalizeReturnPath(newCanonicalPath), + } - newCanonicalPath = '{0}/{1}'.format(newParentPath, filename) + try: + target = self.getObject(newParentPath) + except KeyError: + error = translate(_(u'filemanager_error_folder_exists', + default=u'Destination folder not found.'), + context=self.request) + code = 1 + return { + 'code': code, + 'error': error, + 'newPath': self.normalizeReturnPath(newCanonicalPath), + } + + if filename not in parent: + error = translate(_(u'filemanager_error_file_not_found', + default=u'File not found.'), + context=self.request) + code = 1 + elif filename in target: + error = translate(_(u'filemanager_error_file_exists', + default=u'File already exists.'), + context=self.request) + code = 1 + else: + obj = parent[filename] + del parent[filename] + target[filename] = obj return { 'code': code, diff --git a/plone/resourceeditor/testing.py b/plone/resourceeditor/testing.py index b00a6bd..de5cbe1 100644 --- a/plone/resourceeditor/testing.py +++ b/plone/resourceeditor/testing.py @@ -3,7 +3,6 @@ from plone.app.testing import IntegrationTesting from plone.app.testing import PLONE_FIXTURE from plone.app.testing import PloneSandboxLayer -from zope.configuration import xmlconfig class PloneResourceEditor(PloneSandboxLayer): @@ -12,9 +11,9 @@ class PloneResourceEditor(PloneSandboxLayer): def setUpZope(self, app, configurationContext): # Load ZCML import plone.resourceeditor - xmlconfig.file( + self.loadZCML( 'configure.zcml', - plone.resourceeditor, + package=plone.resourceeditor, context=configurationContext ) @@ -22,6 +21,7 @@ def setUpPloneSite(self, portal): # install plone.resource applyProfile(portal, 'plone.resource:default') + PLONE_RESOURCE_EDITOR_FIXTURE = PloneResourceEditor() PLONE_RESOURCE_EDITOR_INTEGRATION_TESTING = IntegrationTesting( bases=(PLONE_RESOURCE_EDITOR_FIXTURE, ), diff --git a/plone/resourceeditor/tests/__init__.py b/plone/resourceeditor/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plone/resourceeditor/tests.py b/plone/resourceeditor/tests/test_file_manager.py similarity index 99% rename from plone/resourceeditor/tests.py rename to plone/resourceeditor/tests/test_file_manager.py index 410cc3e..f71f123 100644 --- a/plone/resourceeditor/tests.py +++ b/plone/resourceeditor/tests/test_file_manager.py @@ -40,7 +40,7 @@ def test_getfolder(self): r.makeDirectory('alpha') r['alpha'].writeFile('beta.txt', 'Beta') r['alpha'].makeDirectory('delta') - r['alpha']['delta'].writeFile('gamma.css', 'body {}') + r['alpha']['delta'].writeFile('gamma.css', 'body') view = FileManager(r, self.layer['request']) info = view.getFolder('/alpha') diff --git a/plone/resourceeditor/tests/test_file_manager_action.py b/plone/resourceeditor/tests/test_file_manager_action.py new file mode 100644 index 0000000..4fac66c --- /dev/null +++ b/plone/resourceeditor/tests/test_file_manager_action.py @@ -0,0 +1,395 @@ +# -*- coding: utf-8 -*- +from plone.resourceeditor.testing import PLONE_RESOURCE_EDITOR_INTEGRATION_TESTING # noqa + +import unittest + + +class TestResourceEditorOperations(unittest.TestCase): + + layer = PLONE_RESOURCE_EDITOR_INTEGRATION_TESTING + + def _make_directory(self, resourcetype='theme', resourcename='mytheme'): + from plone.resource.interfaces import IResourceDirectory + from zope.component import getUtility + + resources = getUtility(IResourceDirectory, name='persistent') + resources.makeDirectory(resourcetype) + resources[resourcetype].makeDirectory(resourcename) + + return resources[resourcetype][resourcename] + + def test_getinfo(self): + from plone.resourceeditor.browser import FileManagerActions + r = self._make_directory() + + r.writeFile('test.txt', 'A text file') + + view = FileManagerActions(r, self.layer['request']) + info = view.getInfo('/test.txt') + + self.assertEqual(info['code'], 0) + self.assertEqual(info['error'], '') + self.assertEqual(info['fileType'], 'txt') + self.assertEqual(info['filename'], 'test.txt') + self.assertEqual(info['path'], '/test.txt') + + def test_getfolder(self): + from plone.resourceeditor.browser import FileManagerActions + r = self._make_directory() + + r.makeDirectory('alpha') + r['alpha'].writeFile('beta.txt', 'Beta') + r['alpha'].makeDirectory('delta') + r['alpha']['delta'].writeFile('gamma.css', 'body') + + view = FileManagerActions(r, self.layer['request']) + info = view.getFolder('/alpha') + + self.assertEqual(len(info), 2) + + self.assertEqual(info[0]['code'], 0) + self.assertEqual(info[0]['error'], '') + self.assertEqual(info[0]['fileType'], 'dir') + self.assertEqual(info[0]['filename'], 'delta') + self.assertEqual(info[0]['path'], '/alpha/delta') + + self.assertEqual(info[1]['code'], 0) + self.assertEqual(info[1]['error'], '') + self.assertEqual(info[1]['fileType'], 'txt') + self.assertEqual(info[1]['filename'], 'beta.txt') + self.assertEqual(info[1]['path'], '/alpha/beta.txt') + + def test_addfolder(self): + from plone.resourceeditor.browser import FileManagerActions + r = self._make_directory() + + view = FileManagerActions(r, self.layer['request']) + + info = view.addFolder('/', 'alpha') + + self.assertEqual(info['code'], 0) + self.assertEqual(info['error'], '') + self.assertEqual(info['parent'], '/') + self.assertEqual(info['name'], 'alpha') + + info = view.addFolder('/alpha', 'beta') + + self.assertEqual(info['code'], 0) + self.assertEqual(info['error'], '') + self.assertEqual(info['parent'], '/alpha') + self.assertEqual(info['name'], 'beta') + + def test_addfolder_exists(self): + from plone.resourceeditor.browser import FileManagerActions + r = self._make_directory() + r.makeDirectory('alpha') + + view = FileManagerActions(r, self.layer['request']) + + info = view.addFolder('/', 'alpha') + + self.assertEqual(info['code'], 1) + self.assertNotEqual(info['error'], '') + self.assertEqual(info['parent'], '/') + self.assertEqual(info['name'], 'alpha') + + def test_addfolder_invalid_name(self): + from plone.resourceeditor.browser import FileManagerActions + r = self._make_directory() + r.makeDirectory('alpha') + + view = FileManagerActions(r, self.layer['request']) + + for char in '\\/:*?"<>': + info = view.addFolder('/', 'foo' + char) + + self.assertEqual(info['code'], 1) + self.assertNotEqual(info['error'], '') + self.assertEqual(info['parent'], '/') + self.assertEqual(info['name'], 'foo' + char) + + def test_addfolder_invalid_parent(self): + from plone.resourceeditor.browser import FileManagerActions + r = self._make_directory() + + view = FileManagerActions(r, self.layer['request']) + + info = view.addFolder('/alpha', 'beta') + + self.assertEqual(info['code'], 1) + self.assertNotEqual(info['error'], '') + self.assertEqual(info['parent'], '/alpha') + self.assertEqual(info['name'], 'beta') + + def test_add(self): + from plone.resourceeditor.browser import FileManagerActions + from StringIO import StringIO + r = self._make_directory() + + view = FileManagerActions(r, self.layer['request']) + + d = StringIO('foo') + d.filename = 'test.txt' + + info = view.add('/', d) + + self.assertEqual(info['code'], 0) + self.assertEqual(info['error'], '') + self.assertEqual(info['name'], 'test.txt') + self.assertEqual(info['path'], '/') + self.assertEqual(info['parent'], '/') + + def test_add_subfolder(self): + from plone.resourceeditor.browser import FileManagerActions + from StringIO import StringIO + r = self._make_directory() + r.makeDirectory('alpha') + + view = FileManagerActions(r, self.layer['request']) + + d = StringIO('foo') + d.filename = 'test.txt' + + info = view.add('/alpha', d) + + self.assertEqual(info['code'], 0) + self.assertEqual(info['error'], '') + self.assertEqual(info['name'], 'test.txt') + self.assertEqual(info['path'], '/alpha') + self.assertEqual(info['parent'], '/alpha') + + def test_add_exists(self): + from plone.resourceeditor.browser import FileManagerActions + from StringIO import StringIO + r = self._make_directory() + r.writeFile('test.txt', 'boo') + + view = FileManagerActions(r, self.layer['request']) + + d = StringIO('foo') + d.filename = 'test.txt' + + info = view.add('/', d) + + self.assertEqual(info['code'], 1) + self.assertNotEqual(info['error'], '') + + self.assertEqual(r.readFile('test.txt'), 'boo') + + def test_add_replace(self): + from plone.resourceeditor.browser import FileManagerActions + from StringIO import StringIO + r = self._make_directory() + r.writeFile('test.txt', 'boo') + + view = FileManagerActions(r, self.layer['request']) + + d = StringIO('foo') + d.filename = 'test.txt' + + info = view.add('/', d, '/test.txt') + + self.assertEqual(info['code'], 0) + self.assertEqual(info['error'], '') + self.assertEqual(info['name'], 'test.txt') + self.assertEqual(info['path'], '/') + self.assertEqual(info['parent'], '/') + + self.assertEqual(r.readFile('test.txt'), 'foo') + + def test_addnew(self): + from plone.resourceeditor.browser import FileManagerActions + r = self._make_directory() + + view = FileManagerActions(r, self.layer['request']) + + info = view.addNew('/', 'test.txt') + + self.assertEqual(info['code'], 0) + self.assertEqual(info['error'], '') + self.assertEqual(info['name'], 'test.txt') + self.assertEqual(info['parent'], '/') + + self.assertEqual(r.readFile('test.txt'), '') + + def test_addnew_exists(self): + from plone.resourceeditor.browser import FileManagerActions + r = self._make_directory() + r.writeFile('test.txt', 'foo') + + view = FileManagerActions(r, self.layer['request']) + + info = view.addNew('/', 'test.txt') + + self.assertEqual(info['code'], 1) + self.assertNotEqual(info['error'], '') + + self.assertEqual(r.readFile('test.txt'), 'foo') + + def test_addnew_invalidname(self): + from plone.resourceeditor.browser import FileManagerActions + r = self._make_directory() + + view = FileManagerActions(r, self.layer['request']) + + for char in '\\/:*?"<>': + info = view.addNew('/', 'foo' + char) + self.assertEqual(info['code'], 1) + self.assertNotEqual(info['error'], '') + + def test_rename(self): + from plone.resourceeditor.browser import FileManagerActions + r = self._make_directory() + r.writeFile('test.txt', 'foo') + + view = FileManagerActions(r, self.layer['request']) + + info = view.rename('/test.txt', 'foo.txt') + + self.assertEqual(info['code'], 0) + self.assertEqual(info['error'], '') + self.assertEqual(info['oldName'], 'test.txt') + self.assertEqual(info['newName'], 'foo.txt') + self.assertEqual(info['oldParent'], '/') + self.assertEqual(info['newParent'], '/') + + self.assertEqual(r.readFile('foo.txt'), 'foo') + + def test_rename_subfolder(self): + from plone.resourceeditor.browser import FileManagerActions + r = self._make_directory() + r.makeDirectory('alpha') + r['alpha'].writeFile('test.txt', 'foo') + + view = FileManagerActions(r, self.layer['request']) + + info = view.rename('/alpha/test.txt', 'foo.txt') + + self.assertEqual(info['code'], 0) + self.assertEqual(info['error'], '') + self.assertEqual(info['oldName'], 'test.txt') + self.assertEqual(info['newName'], 'foo.txt') + self.assertEqual(info['oldParent'], '/alpha') + self.assertEqual(info['newParent'], '/alpha') + + self.assertEqual(r['alpha'].readFile('foo.txt'), 'foo') + + def test_rename_exists(self): + from plone.resourceeditor.browser import FileManagerActions + r = self._make_directory() + r.writeFile('test.txt', 'foo') + r.writeFile('foo.txt', 'bar') + + view = FileManagerActions(r, self.layer['request']) + + info = view.rename('/test.txt', 'foo.txt') + + self.assertEqual(info['code'], 1) + self.assertNotEqual(info['error'], '') + self.assertEqual(info['oldName'], 'test.txt') + self.assertEqual(info['newName'], 'foo.txt') + self.assertEqual(info['oldParent'], '/') + self.assertEqual(info['newParent'], '/') + + self.assertEqual(r.readFile('foo.txt'), 'bar') + + def test_delete(self): + from plone.resourceeditor.browser import FileManagerActions + r = self._make_directory() + r.writeFile('test.txt', 'foo') + + view = FileManagerActions(r, self.layer['request']) + + info = view.delete('/test.txt') + + self.assertEqual(info['code'], 0) + self.assertEqual(info['error'], '') + self.assertEqual(info['path'], '/test.txt') + + self.assertFalse('test.txt' in r) + + def test_delete_subfolder(self): + from plone.resourceeditor.browser import FileManagerActions + r = self._make_directory() + r.makeDirectory('alpha') + r['alpha'].writeFile('test.txt', 'foo') + + view = FileManagerActions(r, self.layer['request']) + + info = view.delete('/alpha/test.txt') + + self.assertEqual(info['code'], 0) + self.assertEqual(info['error'], '') + self.assertEqual(info['path'], '/alpha/test.txt') + + self.assertFalse('test.txt' in r['alpha']) + + def test_delete_notfound(self): + from plone.resourceeditor.browser import FileManagerActions + r = self._make_directory() + + view = FileManagerActions(r, self.layer['request']) + + info = view.delete('/test.txt') + + self.assertEqual(info['code'], 1) + self.assertNotEqual(info['error'], '') + self.assertEqual(info['path'], '/test.txt') + + def test_move(self): + from plone.resourceeditor.browser import FileManagerActions + r = self._make_directory() + r.makeDirectory('alpha') + r.writeFile('test.txt', 'foo') + + view = FileManagerActions(r, self.layer['request']) + + info = view.move('/test.txt', '/alpha') + + self.assertEqual(info['code'], 0) + self.assertEqual(info['error'], '') + self.assertEqual(info['newPath'], '/alpha/test.txt') + + self.assertFalse('test.txt' in r) + self.assertEqual('foo', r['alpha'].readFile('test.txt')) + + def test_move_exists(self): + from plone.resourceeditor.browser import FileManagerActions + r = self._make_directory() + r.makeDirectory('alpha') + r['alpha'].writeFile('test.txt', 'bar') + r.writeFile('test.txt', 'foo') + + view = FileManagerActions(r, self.layer['request']) + + info = view.move('/test.txt', '/alpha') + + self.assertEqual(info['code'], 1) + self.assertNotEqual(info['error'], '') + self.assertEqual(info['newPath'], '/alpha/test.txt') + + self.assertTrue('test.txt' in r) + self.assertEqual('bar', r['alpha'].readFile('test.txt')) + + def test_move_invalid_parent(self): + from plone.resourceeditor.browser import FileManagerActions + r = self._make_directory() + r.writeFile('test.txt', 'foo') + + view = FileManagerActions(r, self.layer['request']) + + info = view.move('/test.txt', '/alpha') + + self.assertEqual(info['code'], 1) + self.assertNotEqual(info['error'], '') + self.assertEqual(info['newPath'], '/alpha/test.txt') + + self.assertTrue('test.txt' in r) + + def test_download(self): + from plone.resourceeditor.browser import FileManagerActions + r = self._make_directory() + r.writeFile('test.txt', 'foo') + + view = FileManagerActions(r, self.layer['request']) + self.assertEqual('foo', view.download('/test.txt')) ------------------------------------------------------------------------------ Check out the vibrant tech community on one of the world's most engaging tech sites, Slashdot.org! http://sdm.link/slashdot