├── requirements.in ├── src └── nextcloud │ ├── __init__.py │ ├── api_wrappers │ ├── capabilities.py │ ├── __init__.py │ ├── apps.py │ ├── notifications.py │ ├── federated_cloudshares.py │ ├── activity.py │ ├── group.py │ ├── group_folders.py │ ├── user.py │ ├── share.py │ ├── user_ldap.py │ └── webdav.py │ ├── base.py │ ├── NextCloud.py │ ├── response.py │ └── requester.py ├── docs ├── source │ ├── examples.rst │ ├── modules.rst │ ├── nextcloud.rst │ ├── api_wrappers.rst │ ├── index.rst │ ├── introduction.rst │ ├── api_implementation.rst │ └── conf.py ├── Makefile └── make.bat ├── tests ├── __init__.py ├── .env ├── test_capabilities.py ├── test_requester.py ├── README.md ├── test_base.py ├── docker-compose.yml ├── test_apps.py ├── test_notifications.py ├── test_activities.py ├── test_groups.py ├── base.py ├── test_ldap.py ├── test_users.py ├── test_group_folders.py ├── test_shares.py └── test_webdav.py ├── Dockerfile ├── nextcloud └── Dockerfile ├── requirements.txt ├── .travis.yml ├── setup.py ├── README.md ├── example.py ├── .gitignore ├── api_implementation.json └── LICENSE /requirements.in: -------------------------------------------------------------------------------- 1 | requests 2 | pytest -------------------------------------------------------------------------------- /src/nextcloud/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .NextCloud import NextCloud 3 | 4 | -------------------------------------------------------------------------------- /docs/source/examples.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ======== 3 | 4 | Users API methods 5 | ----------------- 6 | 7 | .. include:: ../../example.py 8 | :literal: 9 | -------------------------------------------------------------------------------- /docs/source/modules.rst: -------------------------------------------------------------------------------- 1 | Nextcloud-API 2 | ============= 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | nextcloud 8 | api_wrappers 9 | 10 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from os.path import dirname 3 | from os.path import join 4 | 5 | sys.path.insert(0, join(dirname(dirname(__file__)), 'src')) 6 | -------------------------------------------------------------------------------- /docs/source/nextcloud.rst: -------------------------------------------------------------------------------- 1 | nextcloud module 2 | ================ 3 | 4 | .. automodule:: nextcloud.NextCloud 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | :imported-members: 9 | -------------------------------------------------------------------------------- /docs/source/api_wrappers.rst: -------------------------------------------------------------------------------- 1 | NextCloud api wrappers 2 | ====================== 3 | 4 | .. automodule:: nextcloud.api_wrappers 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | :imported-members: 9 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to nextcloud-API's documentation! 2 | ========================================= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | introduction 9 | examples 10 | modules -------------------------------------------------------------------------------- /tests/.env: -------------------------------------------------------------------------------- 1 | POSTGRES_USER=postgres 2 | POSTGRES_PASSWORD=secret 3 | POSTGRES_DB=nextcloud 4 | POSTGRES_HOST=db 5 | 6 | NEXTCLOUD_VERSION=16 7 | GROUPFOLDERS_VERSION=4.0.5 8 | NEXTCLOUD_HOSTNAME=app 9 | NEXTCLOUD_ADMIN_USER=admin 10 | NEXTCLOUD_ADMIN_PASSWORD=admin 11 | 12 | NEXTCLOUD_HOST=app 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7.1-alpine 2 | 3 | RUN mkdir /usr/src/app 4 | WORKDIR /usr/src/app 5 | RUN pip install pytest-cov 6 | COPY ./requirements.txt . 7 | RUN pip install -r requirements.txt 8 | 9 | COPY . . 10 | 11 | # This is is where the nextcloud package is. 12 | WORKDIR /usr/src/app/src 13 | -------------------------------------------------------------------------------- /tests/test_capabilities.py: -------------------------------------------------------------------------------- 1 | from .base import BaseTestCase, NEXTCLOUD_VERSION 2 | 3 | 4 | class TestCapabilities(BaseTestCase): 5 | 6 | def test_get_capabilities(self): 7 | res = self.nxc.get_capabilities() 8 | assert res.is_ok 9 | assert str(res.data['version']['major']) == NEXTCLOUD_VERSION 10 | -------------------------------------------------------------------------------- /nextcloud/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG NEXTCLOUD_VERSION 2 | 3 | FROM nextcloud:$NEXTCLOUD_VERSION-apache 4 | 5 | ARG GROUPFOLDERS_VERSION 6 | ADD https://github.com/nextcloud/groupfolders/releases/download/v${GROUPFOLDERS_VERSION}/groupfolders-$GROUPFOLDERS_VERSION.tar.gz /tmp/ 7 | 8 | RUN true \ 9 | && tar xf /tmp/groupfolders-$GROUPFOLDERS_VERSION.tar.gz -C /tmp/ \ 10 | && true 11 | -------------------------------------------------------------------------------- /src/nextcloud/api_wrappers/capabilities.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from nextcloud.base import WithRequester 3 | 4 | 5 | class Capabilities(WithRequester): 6 | API_URL = "/ocs/v1.php/cloud/capabilities" 7 | SUCCESS_CODE = 100 8 | 9 | def get_capabilities(self): 10 | """ Obtain capabilities provided by the Nextcloud server and its apps """ 11 | return self.requester.get() 12 | -------------------------------------------------------------------------------- /tests/test_requester.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from nextcloud.requester import Requester, NextCloudConnectionError 4 | 5 | 6 | class TestRequester(TestCase): 7 | 8 | def test_wrong_url(self): 9 | wrong_url = 'http://wrong-url.wrong' 10 | req = Requester(wrong_url, 'user', 'password', json_output=False) 11 | req.API_URL = '/wrong' 12 | exception_raised = False 13 | try: 14 | req.get('') 15 | except NextCloudConnectionError as e: 16 | exception_raised = True 17 | assert wrong_url in str(e) 18 | assert exception_raised 19 | 20 | -------------------------------------------------------------------------------- /src/nextcloud/api_wrappers/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .activity import Activity 3 | from .apps import Apps 4 | from .capabilities import Capabilities 5 | from .federated_cloudshares import FederatedCloudShare 6 | from .group import Group 7 | from .group_folders import GroupFolders 8 | from .notifications import Notifications 9 | from .share import Share 10 | from .user import User 11 | from .user_ldap import UserLDAP 12 | from .webdav import WebDAV 13 | 14 | OCS_API_CLASSES = [Activity, Apps, Capabilities, FederatedCloudShare, Group, GroupFolders, 15 | Notifications, Share, User, UserLDAP] 16 | 17 | WEBDAV_CLASS = WebDAV 18 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = source 8 | BUILDDIR = build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile 3 | # To update, run: 4 | # 5 | # pip-compile --output-file requirements.txt requirements.in 6 | # 7 | atomicwrites==1.2.1 # via pytest 8 | attrs==18.2.0 # via pytest 9 | certifi==2018.11.29 # via requests 10 | chardet==3.0.4 # via requests 11 | idna==2.7 # via requests 12 | more-itertools==4.3.0 # via pytest 13 | pluggy==0.8.0 # via pytest 14 | py==1.7.0 # via pytest 15 | pytest==4.0.1 16 | requests==2.20.1 17 | six==1.11.0 # via more-itertools, pytest 18 | urllib3==1.24.1 # via requests 19 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Tests for nextcloud-API python library 2 | 3 | Integration tests for main functionality provided by python wrapper for NextCloud API. 4 | 5 | # How to run? 6 | 7 | First, build, create and start containers for services in docker-compose: 8 | 9 | docker-compose up 10 | 11 | Enable NextCloud groupfolders application: 12 | 13 | docker-compose exec --user www-data app /bin/bash -c \ 14 | "cp -R /tmp/groupfolders /var/www/html/custom_apps/groupfolders && php occ app:enable groupfolders" 15 | 16 | Run tests: 17 | 18 | docker-compose run --rm python-api python -m pytest ../ 19 | 20 | Run examples: 21 | 22 | docker-compose run --rm python-api python ../example.py 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | services: 4 | - docker 5 | 6 | python: 7 | - "3.6" 8 | 9 | before_script: 10 | - cd tests && docker-compose up --build -d 11 | - sleep 10; until docker-compose exec --user www-data app /bin/bash -c "mkdir -p /var/www/html/custom_apps && cp -R /tmp/groupfolders /var/www/html/custom_apps/groupfolders && php occ app:enable groupfolders"; do sleep 1; done 12 | - pip3 install codecov 13 | 14 | # commands to run tests 15 | script: 16 | - docker-compose run --rm python-api python3 -m pytest --cov . --cov-report xml --cov-report term .. 17 | - docker-compose run --rm python-api python ../example.py 18 | 19 | after_script: 20 | - codecov 21 | - docker-compose down -v 22 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | from . import base 2 | 3 | 4 | def test_nonsense_hostname(): 5 | nxc = base.NextCloud("foo", "bar", "baz") 6 | issues = nxc.get_connection_issues() 7 | assert "foo" in issues 8 | 9 | 10 | def test_nonexistent_hostname(): 11 | nxc = base.NextCloud("http://loooooool.no-way", "bar", "baz") 12 | issues = nxc.get_connection_issues() 13 | assert "loooooool.no-way" in issues 14 | 15 | 16 | def test_bad_password(): 17 | nxc = base.NextCloud( 18 | base.NEXTCLOUD_URL, 19 | base.NEXTCLOUD_USERNAME, "Just Trolling") 20 | issues = nxc.get_connection_issues() 21 | assert "not logged in" in issues 22 | 23 | 24 | def test_ok(): 25 | nxc = base.NextCloud( 26 | base.NEXTCLOUD_URL, 27 | base.NEXTCLOUD_USERNAME, base.NEXTCLOUD_PASSWORD) 28 | issues = nxc.get_connection_issues() 29 | assert not issues 30 | -------------------------------------------------------------------------------- /tests/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | app: 5 | build: 6 | context: ../nextcloud 7 | dockerfile: Dockerfile 8 | args: 9 | NEXTCLOUD_VERSION: ${NEXTCLOUD_VERSION} 10 | GROUPFOLDERS_VERSION: ${GROUPFOLDERS_VERSION} 11 | networks: 12 | backend: 13 | ports: 14 | - 8080:80 15 | environment: 16 | - SQLITE_DATABASE=nextcloud 17 | - NEXTCLOUD_TRUSTED_DOMAINS=${NEXTCLOUD_HOSTNAME} 18 | - NEXTCLOUD_ADMIN_USER 19 | - NEXTCLOUD_ADMIN_PASSWORD 20 | 21 | python-api: 22 | build: 23 | context: ../ 24 | dockerfile: Dockerfile 25 | environment: 26 | - NEXTCLOUD_VERSION 27 | - NEXTCLOUD_HOSTNAME 28 | - NEXTCLOUD_ADMIN_USER 29 | - NEXTCLOUD_ADMIN_PASSWORD 30 | networks: 31 | backend: 32 | volumes: 33 | - ../:/usr/src/app 34 | 35 | networks: 36 | backend: 37 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import setuptools 3 | 4 | SETUPDIR = os.path.dirname(__file__) 5 | PKGDIR = os.path.join(SETUPDIR, 'src') 6 | 7 | with open(os.path.join(SETUPDIR, 'README.md'), 'r') as f: 8 | long_description = f.read() 9 | 10 | 11 | setuptools.setup( 12 | name='nextcloud', 13 | version='0.0.1', 14 | author='EnterpriseyIntranet', 15 | description="Python wrapper for NextCloud api", 16 | long_description=long_description, 17 | long_description_content_type="text/markdown", 18 | url="https://github.com/EnterpriseyIntranet/nextcloud-API", 19 | packages=setuptools.find_packages(PKGDIR), 20 | include_package_data=True, 21 | install_requires=['requests'], 22 | package_dir={'': 'src'}, 23 | classifiers=[ 24 | 'Programming Language :: Python :: 3.6', 25 | 'Programming Language :: Python :: 3.7', 26 | 'License :: OSI Approved :: GNU General Public License (GPL)', 27 | "Operating System :: OS Independent", 28 | ], 29 | ) 30 | -------------------------------------------------------------------------------- /docs/source/introduction.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | 4 | Nextcloud-API is Python (2 and 3) wrapper for NextCloud's API. With it you can manage your 5 | NextCloud instances from Python scripts. 6 | 7 | If you have any question, remark or if you find a bug, don't hesitate to 8 | `open an issue `_. 9 | 10 | 11 | 12 | 13 | Quick start 14 | ----------- 15 | 16 | First, create your NextCloud instance: 17 | 18 | .. include:: ../../example.py 19 | :literal: 20 | :end-before: # Quick start 21 | 22 | Then you can work with NextCloud objects: 23 | 24 | .. include:: ../../example.py 25 | :literal: 26 | :start-after: # Quick start 27 | :end-before: # End quick start 28 | 29 | .. include:: ./api_implementation.rst 30 | 31 | Download and install 32 | -------------------- 33 | 34 | .. code-block:: python 35 | 36 | python setup.py install 37 | 38 | 39 | License 40 | ------- 41 | 42 | Nextcloud-API is licensed under the GNU General Public License v3.0. 43 | 44 | 45 | What's next ? 46 | ------------- 47 | 48 | Check :doc:`examples` and :doc:`modules`. 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NextCloud Python api 2 | 3 | [![Build Status](https://travis-ci.org/EnterpriseyIntranet/nextcloud-API.svg?branch=master)](https://travis-ci.org/EnterpriseyIntranet/nextcloud-API) 4 | [![Documentation Status](https://readthedocs.org/projects/nextcloud-api/badge/?version=latest)](https://nextcloud-api.readthedocs.io/en/latest/?badge=latest) 5 | [![codecov](https://codecov.io/gh/EnterpriseyIntranet/nextcloud-API/branch/master/graph/badge.svg)](https://codecov.io/gh/EnterpriseyIntranet/nextcloud-API) 6 | 7 | 8 | ## Overview 9 | 10 | Python wrapper for NextCloud api 11 | 12 | This is Python wrapper for NextCloud's API. With it you can manage your NextCloud instances from Python scripts. 13 | Tested with python 3.7, NextCloud 14. 14 | 15 | 16 | ## FAQ 17 | 18 | 19 | #### Which APIs does it support ? 20 | 21 | Check out the corresponding [nextcloud API documentation](https://nextcloud-api.readthedocs.io/en/latest/introduction.html#which-api-does-it-support) section. 22 | 23 | 24 | #### How do I use it? 25 | 26 | Check out [the simple example](example.py) and also check out the [unit tests directory](tests). 27 | 28 | 29 | #### What do I do if it doesn't work? 30 | 31 | Don't run away and open a GitHub issue! 32 | -------------------------------------------------------------------------------- /src/nextcloud/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import enum 3 | 4 | 5 | class WithRequester(object): 6 | 7 | API_URL = NotImplementedError 8 | 9 | def __init__(self, requester): 10 | self._requester = requester 11 | 12 | @property 13 | def requester(self): 14 | """ Get requester instance """ 15 | # dynamically set API_URL for requester 16 | self._requester.API_URL = self.API_URL 17 | self._requester.SUCCESS_CODE = getattr(self, 'SUCCESS_CODE', None) 18 | return self._requester 19 | 20 | 21 | class OCSCode(enum.IntEnum): 22 | OK = 100 23 | SERVER_ERROR = 996 24 | NOT_AUTHORIZED = 997 25 | NOT_FOUND = 998 26 | UNKNOWN_ERROR = 999 27 | 28 | 29 | class ShareType(enum.IntEnum): 30 | USER = 0 31 | GROUP = 1 32 | PUBLIC_LINK = 3 33 | FEDERATED_CLOUD_SHARE = 6 34 | 35 | 36 | class Permission(enum.IntEnum): 37 | """ Permission for Share have to be sum of selected permissions """ 38 | READ = 1 39 | UPDATE = 2 40 | CREATE = 4 41 | DELETE = 8 42 | SHARE = 16 43 | ALL = 31 44 | 45 | 46 | QUOTA_UNLIMITED = -3 47 | 48 | 49 | def datetime_to_expire_date(date): 50 | return date.strftime("%Y-%m-%d") 51 | -------------------------------------------------------------------------------- /tests/test_apps.py: -------------------------------------------------------------------------------- 1 | from .base import BaseTestCase 2 | 3 | 4 | class TestApps(BaseTestCase): 5 | 6 | def test_get_enable_disable(self): 7 | res = self.nxc.get_apps() 8 | assert res.is_ok 9 | apps_list = res.data['apps'] 10 | assert len(apps_list) > 0 11 | 12 | # app to edit 13 | app = apps_list[0] 14 | 15 | # disable app 16 | res = self.nxc.disable_app(app) 17 | assert res.is_ok 18 | enabled_apps_list = self.nxc.get_apps(filter="enabled").data['apps'] 19 | assert app not in enabled_apps_list 20 | disabled_apps_list = self.nxc.get_apps(filter="disabled").data['apps'].values() 21 | assert app in disabled_apps_list 22 | 23 | # enable app 24 | res = self.nxc.enable_app(app) 25 | assert res.is_ok 26 | enabled_apps_list = self.nxc.get_apps(filter="enabled").data['apps'] 27 | assert app in enabled_apps_list 28 | disabled_apps_list = self.nxc.get_apps(filter="disabled").data['apps'].values() 29 | assert app not in disabled_apps_list 30 | 31 | # get app 32 | res = self.nxc.get_app(app) 33 | assert res.is_ok 34 | assert res.data['id'] == app 35 | -------------------------------------------------------------------------------- /src/nextcloud/api_wrappers/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from nextcloud.base import WithRequester 3 | 4 | 5 | class Apps(WithRequester): 6 | API_URL = "/ocs/v1.php/cloud/apps" 7 | SUCCESS_CODE = 100 8 | 9 | def get_apps(self, filter=None): 10 | """ 11 | Get a list of apps installed on the Nextcloud server 12 | 13 | :param filter: str, optional "enabled" or "disabled" 14 | :return: 15 | """ 16 | params = { 17 | "filter": filter 18 | } 19 | return self.requester.get(params=params) 20 | 21 | def get_app(self, app_id): 22 | """ 23 | Provide information on a specific application 24 | 25 | :param app_id: str, app id 26 | :return: 27 | """ 28 | return self.requester.get(app_id) 29 | 30 | def enable_app(self, app_id): 31 | """ 32 | Enable an app 33 | 34 | :param app_id: str, app id 35 | :return: 36 | """ 37 | return self.requester.post(app_id) 38 | 39 | def disable_app(self, app_id): 40 | """ 41 | Disable the specified app 42 | 43 | :param app_id: str, app id 44 | :return: 45 | """ 46 | return self.requester.delete(app_id) 47 | -------------------------------------------------------------------------------- /src/nextcloud/api_wrappers/notifications.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from nextcloud.base import WithRequester 3 | 4 | 5 | class Notifications(WithRequester): 6 | API_URL = "/ocs/v2.php/apps/notifications/api/v2/notifications" 7 | SUCCESS_CODE = 200 8 | 9 | def get_notifications(self): 10 | """ Get list of notifications for a logged in user """ 11 | return self.requester.get() 12 | 13 | def get_notification(self, notification_id): 14 | """ 15 | Get single notification by id for a user 16 | 17 | Args: 18 | notification_id (int): Notification id 19 | 20 | Returns: 21 | 22 | """ 23 | return self.requester.get(url=notification_id) 24 | 25 | def delete_notification(self, notification_id): 26 | """ 27 | Delete single notification by id for a user 28 | 29 | Args: 30 | notification_id (int): Notification id 31 | 32 | Returns: 33 | 34 | """ 35 | return self.requester.delete(url=notification_id) 36 | 37 | def delete_all_notifications(self): 38 | """ Delete all notification for a logged in user 39 | 40 | Notes: 41 | This endpoint was added for Nextcloud 14 42 | """ 43 | return self.requester.delete() 44 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | from os.path import dirname 4 | from os.path import join 5 | 6 | sys.path.insert(0, join(dirname(__file__), 'src')) 7 | 8 | from nextcloud import NextCloud 9 | 10 | NEXTCLOUD_URL = "http://{}:80".format(os.environ['NEXTCLOUD_HOSTNAME']) 11 | NEXTCLOUD_USERNAME = os.environ.get('NEXTCLOUD_ADMIN_USER') 12 | NEXTCLOUD_PASSWORD = os.environ.get('NEXTCLOUD_ADMIN_PASSWORD') 13 | 14 | # True if you want to get response as JSON 15 | # False if you want to get response as XML 16 | to_js = True 17 | 18 | nxc = NextCloud(endpoint=NEXTCLOUD_URL, user=NEXTCLOUD_USERNAME, password=NEXTCLOUD_PASSWORD, json_output=to_js) 19 | 20 | # Quick start 21 | nxc.get_users() 22 | new_user_id = "new_user_username" 23 | add_user_res = nxc.add_user(new_user_id, "new_user_password321_123") 24 | group_name = "new_group_name" 25 | add_group_res = nxc.add_group(group_name) 26 | add_to_group_res = nxc.add_to_group(new_user_id, group_name) 27 | # End quick start 28 | 29 | assert add_group_res.status_code == 100 30 | assert new_user_id in nxc.get_group(group_name).data['users'] 31 | assert add_user_res.status_code == 100 32 | assert add_to_group_res.status_code == 100 33 | 34 | # remove user 35 | remove_user_res = nxc.delete_user(new_user_id) 36 | assert remove_user_res.status_code == 100 37 | user_res = nxc.get_user(new_user_id) 38 | assert user_res.status_code == 404 39 | -------------------------------------------------------------------------------- /tests/test_notifications.py: -------------------------------------------------------------------------------- 1 | from .base import BaseTestCase 2 | 3 | 4 | class TestNotifications(BaseTestCase): 5 | 6 | def test_get_delete_notifications(self): 7 | # get all notifications 8 | res = self.nxc.get_notifications() 9 | all_data = res.data 10 | assert res.is_ok 11 | 12 | if len(all_data): 13 | notification = all_data[0] 14 | 15 | # get single notification 16 | res = self.nxc.get_notification(notification['notification_id']) 17 | assert res.is_ok 18 | assert res.data['notification_id'] == notification['notification_id'] 19 | 20 | # delete single notification 21 | res = self.nxc.delete_notification(notification['notification_id']) 22 | assert res.is_ok 23 | else: 24 | # assert get single notification will return 404 not found 25 | res = self.nxc.get_notification(1) 26 | assert res.status_code == self.NOT_FOUND_CODE 27 | 28 | # delete all notifications success code check 29 | res = self.nxc.delete_all_notifications() 30 | assert res.is_ok 31 | 32 | # TODO: add more tests if WebDAV api will be implemented and there will be ability to make actions 33 | # using api which creates notifications (mentions in comments) 34 | # or when Federated file sharings api will be implemented (harder way) 35 | -------------------------------------------------------------------------------- /src/nextcloud/api_wrappers/federated_cloudshares.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from nextcloud.base import WithRequester 3 | 4 | 5 | class FederatedCloudShare(WithRequester): 6 | API_URL = "/ocs/v2.php/apps/files_sharing/api/v1" 7 | FEDERATED = "remote_shares" 8 | SUCCESS_CODE = 200 9 | 10 | def get_federated_url(self, additional_url=""): 11 | if additional_url: 12 | return "/".join([self.FEDERATED, additional_url]) 13 | return self.FEDERATED 14 | 15 | def list_accepted_federated_cloudshares(self): 16 | url = self.get_federated_url() 17 | return self.requester.get(url) 18 | 19 | def get_known_federated_cloudshare(self, sid): 20 | url = self.get_federated_url(sid) 21 | return self.requester.get(url) 22 | 23 | def delete_accepted_federated_cloudshare(self, sid): 24 | url = self.get_federated_url(sid) 25 | return self.requester.delete(url) 26 | 27 | def list_pending_federated_cloudshares(self): 28 | url = self.get_federated_url("pending") 29 | return self.requester.get(url) 30 | 31 | def accept_pending_federated_cloudshare(self, sid): 32 | url = self.get_federated_url("pending/{sid}".format(sid=sid)) 33 | return self.requester.post(url) 34 | 35 | def decline_pending_federated_cloudshare(self, sid): 36 | url = self.get_federated_url("pending/{sid}".format(sid=sid)) 37 | return self.requester.delete(url) 38 | -------------------------------------------------------------------------------- /src/nextcloud/api_wrappers/activity.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from nextcloud.base import WithRequester 3 | 4 | 5 | class Activity(WithRequester): 6 | API_URL = "/ocs/v2.php/apps/activity/api/v2/activity" 7 | SUCCESS_CODE = 200 8 | 9 | def get_activities(self, since=None, limit=None, object_type=None, object_id=None, sort=None): 10 | """ 11 | Get an activity feed showing your file changes and other interesting things going on 12 | in your Nextcloud 13 | 14 | All args are optional 15 | 16 | Args: 17 | since (int): ID of the last activity that you’ve seen 18 | limit (int): How many activities should be returned (default: 50) 19 | object_type (string): Filter the activities to a given object. 20 | May only appear together with object_id 21 | object_id (string): Filter the activities to a given object. 22 | May only appear together with object_type 23 | sort (str, "asc" or "desc"): Sort activities ascending or descending (from the since) 24 | (Default: desc) 25 | 26 | Returns: 27 | 28 | """ 29 | params = dict( 30 | since=since, 31 | limit=limit, 32 | object_type=object_type, 33 | object_id=object_id, 34 | sort=sort 35 | ) 36 | if params['object_type'] and params['object_id']: 37 | return self.requester.get(url="filter", params=params) 38 | return self.requester.get(params=params) 39 | -------------------------------------------------------------------------------- /src/nextcloud/NextCloud.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .requester import OCSRequester, WebDAVRequester 3 | from .api_wrappers import OCS_API_CLASSES, WEBDAV_CLASS 4 | 5 | 6 | class NextCloud(object): 7 | 8 | def __init__(self, endpoint, user, password, json_output=True): 9 | self.user = user 10 | self.query_components = [] 11 | 12 | ocs_requester = OCSRequester(endpoint, user, password, json_output) 13 | webdav_requester = WebDAVRequester(endpoint, user, password) 14 | 15 | self.functionality_classes = [api_class(ocs_requester) for api_class in OCS_API_CLASSES] 16 | self.functionality_classes.append(WEBDAV_CLASS(webdav_requester, json_output=json_output)) 17 | 18 | for functionality_class in self.functionality_classes: 19 | for potential_method in dir(functionality_class): 20 | if( 21 | potential_method.startswith('_') 22 | or not callable(getattr(functionality_class, potential_method)) 23 | ): 24 | continue 25 | setattr(self, potential_method, getattr(functionality_class, potential_method)) 26 | 27 | def get_connection_issues(self): 28 | """ 29 | Return Falsy falue if everything is OK, or string representing 30 | the connection problem (bad hostname, password, whatever) 31 | """ 32 | try: 33 | response = self.get_user(self.user) 34 | except Exception as e: 35 | return str(e) 36 | 37 | if not response.is_ok: 38 | return response.meta["message"] 39 | -------------------------------------------------------------------------------- /docs/source/api_implementation.rst: -------------------------------------------------------------------------------- 1 | Which API does it support? 2 | -------------------------- 3 | ============================= ===================== ================= 4 | API name Implementation status Last checked date 5 | ============================= ===================== ================= 6 | `User provisioning API`_ OK 2019-02-02 7 | `OCS Share API`_ Partially implemented 2019-02-02 8 | `WebDAV API`_ OK 2019-02-02 9 | `Activity app API`_ OK 2019-02-02 10 | `Notifications app API`_ OK 2019-02-02 11 | `The LDAP configuration API`_ OK 2019-02-02 12 | `Capabilities API`_ OK 2019-02-02 13 | `Group Folders API`_ OK 2019-02-02 14 | ============================= ===================== ================= 15 | 16 | .. _User provisioning API: https://docs.nextcloud.com/server/14/admin_manual/configuration_user/user_provisioning_api.html 17 | .. _OCS Share API: https://docs.nextcloud.com/server/14/developer_manual/core/ocs-share-api.html 18 | .. _WebDAV API: https://docs.nextcloud.com/server/14/developer_manual/client_apis/WebDAV/index.html 19 | .. _Activity app API: https://github.com/nextcloud/activity 20 | .. _Notifications app API: https://github.com/nextcloud/notifications/ 21 | .. _The LDAP configuration API: https://docs.nextcloud.com/server/14/admin_manual/configuration_user/user_auth_ldap_api.html 22 | .. _Capabilities API: https://docs.nextcloud.com/server/14/developer_manual/client_apis/OCS/index.html#capabilities-api 23 | .. _Group Folders API: https://github.com/nextcloud/groupfolders -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # PyCharm 74 | .idea/ 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | -------------------------------------------------------------------------------- /src/nextcloud/api_wrappers/group.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from nextcloud.base import WithRequester 3 | 4 | 5 | class Group(WithRequester): 6 | API_URL = "/ocs/v1.php/cloud/groups" 7 | SUCCESS_CODE = 100 8 | 9 | def get_groups(self, search=None, limit=None, offset=None): 10 | """ 11 | Retrieve a list of groups from the Nextcloud server 12 | 13 | :param search: string, optional search string 14 | :param limit: int, optional limit value 15 | :param offset: int, optional offset value 16 | :return: 17 | """ 18 | params = { 19 | 'search': search, 20 | 'limit': limit, 21 | 'offset': offset 22 | } 23 | return self.requester.get(params=params) 24 | 25 | def add_group(self, gid): 26 | """ 27 | Add a new group 28 | 29 | :param gid: str, group name 30 | :return: 31 | """ 32 | msg = {"groupid": gid} 33 | return self.requester.post("", msg) 34 | 35 | def get_group(self, gid): 36 | """ 37 | Retrieve a list of group members 38 | 39 | :param gid: str, group name 40 | :return: 41 | """ 42 | return self.requester.get("{gid}".format(gid=gid)) 43 | 44 | def get_subadmins(self, gid): 45 | """ 46 | List subadmins of the group 47 | 48 | :param gid: str, group name 49 | :return: 50 | """ 51 | return self.requester.get("{gid}/subadmins".format(gid=gid)) 52 | 53 | def delete_group(self, gid): 54 | """ 55 | Remove a group 56 | 57 | :param gid: str, group name 58 | :return: 59 | """ 60 | return self.requester.delete("{gid}".format(gid=gid)) 61 | -------------------------------------------------------------------------------- /tests/test_activities.py: -------------------------------------------------------------------------------- 1 | from .base import BaseTestCase 2 | 3 | 4 | class TestActivities(BaseTestCase): 5 | 6 | def test_get_filter_activities(self): 7 | res = self.nxc.get_activities() 8 | all_data = res.data 9 | assert res.is_ok 10 | 11 | # test limit 12 | res = self.nxc.get_activities(limit=1) 13 | assert res.is_ok 14 | assert len(res.data) <= 1 15 | 16 | # test ascending sorting 17 | res = self.nxc.get_activities(sort="asc") 18 | assert res.is_ok 19 | data = res.data 20 | for num in range(1, len(data)): 21 | assert data[num - 1]['activity_id'] <= data[num]['activity_id'] 22 | 23 | # test descending sorting 24 | res = self.nxc.get_activities(sort="desc") 25 | assert res.is_ok 26 | data = res.data 27 | for num in range(1, len(data)): 28 | assert data[num - 1]['activity_id'] >= data[num]['activity_id'] 29 | 30 | # TODO: add more reliable tests for since, object_id, object_type parameters, if WebDAV Directory API will be 31 | # implemented and it will be possible to make files manipulation to create activities from api 32 | 33 | # not reliable test for filter by object_id and object_type 34 | if len(all_data): 35 | object_to_filter_by = all_data[0] 36 | res = self.nxc.get_activities(object_id=object_to_filter_by['object_id'], 37 | object_type=object_to_filter_by['object_type']) 38 | assert res.is_ok 39 | data = res.data 40 | assert len(data) >= 1 41 | for each in data: 42 | assert each['object_id'] == object_to_filter_by['object_id'] 43 | assert each['object_type'] == object_to_filter_by['object_type'] 44 | -------------------------------------------------------------------------------- /api_implementation.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Which API does it support?", 3 | "headers": ["API name", "Implementation status", "Last checked date"], 4 | "headers attributes": ["name", "implementation status", "date_last_checked"], 5 | "data": [ 6 | { 7 | "name": "User provisioning API", 8 | "url": "https://docs.nextcloud.com/server/14/admin_manual/configuration_user/user_provisioning_api.html", 9 | "implementation status": "OK", 10 | "date_last_checked": "2019-02-02" 11 | }, 12 | { 13 | "name": "OCS Share API", 14 | "url": "https://docs.nextcloud.com/server/14/developer_manual/core/ocs-share-api.html", 15 | "implementation status": "Partially implemented", 16 | "date_last_checked": "2019-02-02" 17 | }, 18 | { 19 | "name": "WebDAV API", 20 | "url": "https://docs.nextcloud.com/server/14/developer_manual/client_apis/WebDAV/index.html", 21 | "implementation status": "OK", 22 | "date_last_checked": "2019-02-02" 23 | }, 24 | { 25 | "name": "Activity app API", 26 | "url": "https://github.com/nextcloud/activity", 27 | "implementation status": "OK", 28 | "date_last_checked": "2019-02-02" 29 | }, 30 | { 31 | "name": "Notifications app API", 32 | "url": "https://github.com/nextcloud/notifications/", 33 | "implementation status": "OK", 34 | "date_last_checked": "2019-02-02" 35 | }, 36 | {"name": "The LDAP configuration API", 37 | "url": "https://docs.nextcloud.com/server/14/admin_manual/configuration_user/user_auth_ldap_api.html", 38 | "implementation status": "OK", 39 | "date_last_checked": "2019-02-02" 40 | }, 41 | { 42 | "name": "Capabilities API", 43 | "url": "https://docs.nextcloud.com/server/14/developer_manual/client_apis/OCS/index.html#capabilities-api", 44 | "implementation status": "OK", 45 | "date_last_checked": "2019-02-02" 46 | }, 47 | { 48 | "name": "Group Folders API", 49 | "url": "https://github.com/nextcloud/groupfolders", 50 | "implementation status": "OK", 51 | "date_last_checked": "2019-02-02" 52 | } 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /tests/test_groups.py: -------------------------------------------------------------------------------- 1 | from .base import BaseTestCase 2 | 3 | 4 | class TestGroups(BaseTestCase): 5 | 6 | def setUp(self): 7 | super(TestGroups, self).setUp() 8 | self.user_username = self.create_new_user('user_group_') 9 | # create group 10 | self.group_name = self.get_random_string(length=4) + "_test_groups" 11 | self.nxc.add_group(self.group_name) 12 | 13 | def tearDown(self): 14 | self.delete_user(self.user_username) 15 | self.nxc.delete_group(self.group_name) 16 | 17 | def test_get_groups(self): 18 | res = self.nxc.get_groups() 19 | assert res.status_code == self.SUCCESS_CODE 20 | assert len(res.data['groups']) > 0 21 | # test search argument 22 | res = self.nxc.get_groups(search=self.group_name) 23 | groups = res.data['groups'] 24 | assert [self.group_name, "everyone"] == groups or [self.group_name] == groups 25 | # test limit argument 26 | res = self.nxc.get_groups(limit=0) 27 | assert len(res.data['groups']) == 0 28 | 29 | def test_add_get_group(self): 30 | group_name = self.get_random_string(length=4) + "_test_add" 31 | res = self.nxc.add_group(group_name) 32 | assert res.is_ok 33 | # get single group 34 | res = self.nxc.get_group(group_name) 35 | assert res.is_ok 36 | # assuming logged in user is admin 37 | res = self.nxc.get_group("admin") 38 | assert res.is_ok 39 | group_users = res.data['users'] 40 | assert self.username in group_users 41 | 42 | def test_delete_group(self): 43 | group_name = self.get_random_string(length=4) + "_test_delete" 44 | self.nxc.add_group(group_name) 45 | res = self.nxc.delete_group(group_name) 46 | assert res.is_ok 47 | res = self.nxc.get_group(group_name) 48 | assert res.status_code == self.NOT_FOUND_CODE 49 | 50 | def test_group_subadmins(self): 51 | self.nxc.create_subadmin(self.user_username, self.group_name) 52 | res = self.nxc.get_subadmins(self.group_name) 53 | assert res.is_ok 54 | assert res.data == [self.user_username] 55 | -------------------------------------------------------------------------------- /src/nextcloud/response.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from json import JSONDecodeError 3 | 4 | from .api_wrappers.webdav import WebDAVStatusCodes 5 | 6 | 7 | class NextCloudResponse(object): 8 | 9 | def __init__(self, response, json_output=True, data=None): 10 | self.raw = response 11 | if not data: 12 | self.data = response.json() if json_output else response.content.decode("UTF-8") 13 | else: 14 | self.data = data 15 | 16 | 17 | class WebDAVResponse(NextCloudResponse): 18 | """ Response class for WebDAV api methods """ 19 | 20 | METHODS_SUCCESS_CODES = { 21 | "PROPFIND": [WebDAVStatusCodes.MULTISTATUS_CODE], 22 | "PROPPATCH": [WebDAVStatusCodes.MULTISTATUS_CODE], 23 | "REPORT": [WebDAVStatusCodes.MULTISTATUS_CODE], 24 | "MKCOL": [WebDAVStatusCodes.CREATED_CODE], 25 | "COPY": [WebDAVStatusCodes.CREATED_CODE, WebDAVStatusCodes.NO_CONTENT_CODE], 26 | "MOVE": [WebDAVStatusCodes.CREATED_CODE, WebDAVStatusCodes.NO_CONTENT_CODE], 27 | "PUT": [WebDAVStatusCodes.CREATED_CODE], 28 | "DELETE": [WebDAVStatusCodes.NO_CONTENT_CODE] 29 | } 30 | 31 | def __init__(self, response, data=None): 32 | super(WebDAVResponse, self).__init__(response=response, data=data, json_output=False) 33 | request_method = response.request.method 34 | self.is_ok = False 35 | if request_method in self.METHODS_SUCCESS_CODES: 36 | self.is_ok = response.status_code in self.METHODS_SUCCESS_CODES[request_method] 37 | 38 | def __repr__(self): 39 | is_ok_str = "OK" if self.is_ok else "Failed" 40 | return "".format(is_ok_str) 41 | 42 | 43 | class OCSResponse(NextCloudResponse): 44 | """ Response class for OCS api methods """ 45 | 46 | def __init__(self, response, json_output=True, success_code=None): 47 | self.raw = response 48 | self.is_ok = None 49 | 50 | if json_output: 51 | try: 52 | self.full_data = response.json() 53 | self.meta = self.full_data['ocs']['meta'] 54 | self.status_code = self.full_data['ocs']['meta']['statuscode'] 55 | self.data = self.full_data['ocs']['data'] 56 | if success_code: 57 | self.is_ok = self.full_data['ocs']['meta']['statuscode'] == success_code 58 | except JSONDecodeError: 59 | self.is_ok = False 60 | self.data = {'message': 'Unable to parse JSON response'} 61 | else: 62 | self.data = response.content.decode("UTF-8") 63 | 64 | def __repr__(self): 65 | is_ok_str = "OK" if self.is_ok else "Failed" 66 | return "".format(is_ok_str) 67 | -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | import string 4 | from unittest import TestCase 5 | 6 | from nextcloud import NextCloud 7 | 8 | NEXTCLOUD_VERSION = os.environ.get('NEXTCLOUD_VERSION') 9 | NEXTCLOUD_URL = "http://{}:80".format(os.environ.get('NEXTCLOUD_HOSTNAME', 'localhost')) 10 | NEXTCLOUD_USERNAME = os.environ.get('NEXTCLOUD_ADMIN_USER', 'admin') 11 | NEXTCLOUD_PASSWORD = os.environ.get('NEXTCLOUD_ADMIN_PASSWORD', 'admin') 12 | 13 | 14 | class BaseTestCase(TestCase): 15 | 16 | SUCCESS_CODE = 100 17 | INVALID_INPUT_CODE = 101 18 | USERNAME_ALREADY_EXISTS_CODE = 102 19 | UNKNOWN_ERROR_CODE = 103 20 | NOT_FOUND_CODE = 404 21 | 22 | def setUp(self): 23 | self.username = NEXTCLOUD_USERNAME 24 | self.nxc = NextCloud(NEXTCLOUD_URL, NEXTCLOUD_USERNAME, NEXTCLOUD_PASSWORD, json_output=True) 25 | 26 | def create_new_user(self, username_prefix, password=None): 27 | """ Helper method to create new user """ 28 | new_user_username = username_prefix + self.get_random_string(length=4) 29 | user_password = password or self.get_random_string(length=8) 30 | res = self.nxc.add_user(new_user_username, user_password) 31 | assert res.is_ok 32 | return new_user_username 33 | 34 | def delete_user(self, username): 35 | """ Helper method to delete user by username """ 36 | res = self.nxc.delete_user(username) 37 | assert res.is_ok 38 | 39 | def clear(self, nxc=None, user_ids=None, group_ids=None, share_ids=None, group_folder_ids=None): 40 | """ 41 | Delete created objects during tests 42 | 43 | Args: 44 | nxc (NextCloud object): (optional) Nextcloud instance, if not given - self.nxc is used 45 | user_ids (list): list of user ids 46 | group_ids (list): list of group ids 47 | share_ids (list): list of shares ids 48 | 49 | Returns: 50 | 51 | """ 52 | nxc = nxc or self.nxc 53 | if share_ids: 54 | for share_id in share_ids: 55 | res = nxc.delete_share(share_id) 56 | assert res.is_ok 57 | if group_ids: 58 | for group_id in group_ids: 59 | res = nxc.delete_group(group_id) 60 | assert res.is_ok 61 | if user_ids: 62 | for user_id in user_ids: 63 | res = nxc.delete_user(user_id) 64 | assert res.is_ok 65 | if group_folder_ids: 66 | for group_folder_id in group_folder_ids: 67 | res = nxc.delete_group_folder(group_folder_id) 68 | assert res.is_ok 69 | 70 | def get_random_string(self, length=6): 71 | """ Helper method to get random string with set length """ 72 | return ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(length)) 73 | 74 | 75 | class LocalNxcUserMixin(object): 76 | """Mixin to create new clean NextCloud admin user for tests and delete it on tear down""" 77 | 78 | def setUp(self): 79 | super(LocalNxcUserMixin, self).setUp() 80 | user_password = self.get_random_string(length=8) 81 | self.user_username = self.create_new_user('test_user_', password=user_password) 82 | self.nxc_local = self.nxc_local = NextCloud(NEXTCLOUD_URL, self.user_username, user_password, json_output=True) 83 | # make user admin 84 | self.nxc.add_to_group(self.user_username, 'admin') 85 | 86 | def tearDown(self): 87 | self.nxc.delete_user(self.user_username) 88 | -------------------------------------------------------------------------------- /tests/test_ldap.py: -------------------------------------------------------------------------------- 1 | import re 2 | from unittest.mock import patch 3 | 4 | from .base import BaseTestCase 5 | 6 | from nextcloud.api_wrappers.user_ldap import UserLDAP 7 | 8 | 9 | class TestUserLDAP(BaseTestCase): 10 | 11 | def setUp(self): 12 | super(TestUserLDAP, self).setUp() 13 | self.nxc.enable_app('user_ldap') 14 | 15 | def test_crud_ldap_config(self): 16 | res = self.nxc.create_ldap_config() 17 | assert res.is_ok 18 | config_id = res.data['configID'] 19 | 20 | # We assume that the ID has format s{number} 21 | config_number = int(config_id[1:]) 22 | inferred_config_id = self.nxc.get_ldap_lowest_existing_config_id( 23 | config_number - 2, config_number + 2) 24 | res = self.nxc.get_ldap_config(inferred_config_id) 25 | assert res.is_ok 26 | 27 | self.nxc.ldap_cache_flush(config_id) 28 | 29 | # test get config by id 30 | res = self.nxc.get_ldap_config(config_id) 31 | assert res.is_ok 32 | config_data = res.data 33 | 34 | # test edit config 35 | param_to_change = "ldapPagingSize" 36 | old_param_value = config_data[param_to_change] 37 | new_param_value = 777 38 | assert old_param_value != new_param_value 39 | res = self.nxc.edit_ldap_config(config_id, data={param_to_change: new_param_value}) 40 | assert res.is_ok 41 | new_config_data = self.nxc.get_ldap_config(config_id).data 42 | assert str(new_config_data[param_to_change]) == str(new_param_value) 43 | 44 | # test showPassword param 45 | ldap_password_param = "ldapAgentPassword" 46 | ldap_password_value = "test_password" 47 | self.nxc.edit_ldap_config(config_id, {ldap_password_param: ldap_password_value}) 48 | config_data_without_password = self.nxc.get_ldap_config(config_id).data 49 | assert config_data_without_password[ldap_password_param] == "***" 50 | config_data_with_password = self.nxc.get_ldap_config(config_id, show_password=1).data 51 | assert config_data_with_password[ldap_password_param] == ldap_password_value 52 | 53 | # test delete config 54 | res = self.nxc.delete_ldap_config(config_id) 55 | assert res.is_ok 56 | res = self.nxc.get_ldap_config(config_id) 57 | assert res.status_code == self.NOT_FOUND_CODE 58 | 59 | def test_ldap_setters_getters(self): 60 | res = self.nxc.create_ldap_config() 61 | assert res.is_ok 62 | assert res.status_code == 200 63 | config_id = res.data['configID'] 64 | 65 | for ldap_key in UserLDAP.CONFIG_KEYS: 66 | key_name = re.sub('ldap', '', ldap_key) 67 | key_name = re.sub('([a-z0-9])([A-Z])', r'\1_\2', key_name).lower() 68 | 69 | getter_name = "get_ldap_{}".format(key_name) 70 | setter_name = "set_ldap_{}".format(key_name) 71 | # test if method exist 72 | assert hasattr(self.nxc, setter_name) 73 | assert hasattr(self.nxc, getter_name) 74 | 75 | # test getter 76 | getter_value = getattr(self.nxc, getter_name)(config_id) 77 | config_value = self.nxc.get_ldap_config(config_id).data[ldap_key] 78 | assert getter_value == config_value 79 | 80 | # test setter 81 | value = 1 82 | with patch.object(UserLDAP, 'edit_ldap_config', return_value=1) as mock_method: 83 | setter_method = getattr(self.nxc, setter_name) 84 | setter_method(config_id, value) 85 | mock_method.assert_called_with(config_id, data={ldap_key: value}) 86 | -------------------------------------------------------------------------------- /src/nextcloud/api_wrappers/group_folders.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from nextcloud.base import WithRequester 3 | 4 | 5 | class GroupFolders(WithRequester): 6 | API_URL = "/apps/groupfolders/folders" 7 | SUCCESS_CODE = 100 8 | 9 | def get_group_folders(self): 10 | """ 11 | Return a list of call configured folders and their settings 12 | 13 | Returns: 14 | 15 | """ 16 | return self.requester.get() 17 | 18 | def get_group_folder(self, fid): 19 | """ 20 | Return a specific configured folder and it's settings 21 | 22 | Args: 23 | fid (int/str): group folder id 24 | 25 | Returns: 26 | 27 | """ 28 | return self.requester.get(fid) 29 | 30 | def create_group_folder(self, mountpoint): 31 | """ 32 | Create a new group folder 33 | 34 | Args: 35 | mountpoint (str): name for the new folder 36 | 37 | Returns: 38 | 39 | """ 40 | return self.requester.post(data={"mountpoint": mountpoint}) 41 | 42 | def delete_group_folder(self, fid): 43 | """ 44 | Delete a group folder 45 | 46 | Args: 47 | fid (int/str): group folder id 48 | 49 | Returns: 50 | 51 | """ 52 | return self.requester.delete(fid) 53 | 54 | def grant_access_to_group_folder(self, fid, gid): 55 | """ 56 | Give a group access to a folder 57 | 58 | Args: 59 | fid (int/str): group folder id 60 | gid (str): group to share with id 61 | 62 | Returns: 63 | 64 | """ 65 | url = "/".join([str(fid), "groups"]) 66 | return self.requester.post(url, data={"group": gid}) 67 | 68 | def revoke_access_to_group_folder(self, fid, gid): 69 | """ 70 | Remove access from a group to a folder 71 | 72 | Args: 73 | fid (int/str): group folder id 74 | gid (str): group id 75 | 76 | Returns: 77 | 78 | """ 79 | url = "/".join([str(fid), "groups", gid]) 80 | return self.requester.delete(url) 81 | 82 | def set_permissions_to_group_folder(self, fid, gid, permissions): 83 | """ 84 | Set the permissions a group has in a folder 85 | 86 | Args: 87 | fid (int/str): group folder id 88 | gid (str): group id 89 | permissions (int): The new permissions for the group as attribute of Permission class 90 | 91 | Returns: 92 | 93 | """ 94 | url = "/".join([str(fid), "groups", gid]) 95 | return self.requester.post(url=url, data={"permissions": permissions}) 96 | 97 | def set_quota_of_group_folder(self, fid, quota): 98 | """ 99 | Set the quota for a folder in bytes 100 | 101 | Args: 102 | fid (int/str): group folder id 103 | quota (int/str): The new quota for the folder in bytes, user -3 for unlimited 104 | 105 | Returns: 106 | 107 | """ 108 | url = "/".join([str(fid), "quota"]) 109 | return self.requester.post(url, {"quota": quota}) 110 | 111 | def rename_group_folder(self, fid, mountpoint): 112 | """ 113 | Change the name of a folder 114 | 115 | Args: 116 | fid (int/str): group folder id 117 | mountpoint (str): The new name for the folder 118 | 119 | Returns: 120 | 121 | """ 122 | url = "/".join([str(fid), "mountpoint"]) 123 | return self.requester.post(url=url, data={"mountpoint": mountpoint}) 124 | -------------------------------------------------------------------------------- /tests/test_users.py: -------------------------------------------------------------------------------- 1 | from .base import BaseTestCase 2 | 3 | 4 | class TestUsers(BaseTestCase): 5 | 6 | def test_get_users(self): 7 | res = self.nxc.get_users() 8 | assert res.is_ok 9 | assert len(res.data['users']) > 0 10 | # test search argument 11 | res = self.nxc.get_users(search=self.username) 12 | users = res.data['users'] 13 | assert all([self.username in user for user in users]) 14 | # test limit argument 15 | res = self.nxc.get_users(limit=0) 16 | assert len(res.data['users']) == 0 17 | 18 | def test_get_user(self): 19 | res = self.nxc.get_user(self.username) 20 | assert res.is_ok 21 | user = res.data 22 | assert user['id'] == self.username 23 | assert user['enabled'] 24 | 25 | def test_add_user(self): 26 | new_user_username = self.get_random_string(length=4) + "test_add" 27 | res = self.nxc.add_user(new_user_username, self.get_random_string(length=8)) 28 | assert res.is_ok 29 | added_user = self.nxc.get_user(new_user_username).data 30 | assert added_user['id'] == new_user_username and added_user['enabled'] 31 | # adding same user will fail 32 | res = self.nxc.add_user(new_user_username, "test_password_123") 33 | assert res.status_code == self.USERNAME_ALREADY_EXISTS_CODE 34 | 35 | def test_disable_enable_user(self): 36 | new_user_username = self.get_random_string(length=4) + "test_enable" 37 | self.nxc.add_user(new_user_username, self.get_random_string(length=8)) 38 | user = self.nxc.get_user(new_user_username).data 39 | assert user['enabled'] 40 | self.nxc.disable_user(new_user_username) 41 | user = self.nxc.get_user(new_user_username).data 42 | assert not user['enabled'] 43 | self.nxc.enable_user(new_user_username) 44 | user = self.nxc.get_user(new_user_username).data 45 | assert user['enabled'] 46 | 47 | def test_delete_user(self): 48 | new_user_username = self.get_random_string(length=4) + "test_enable" 49 | self.nxc.add_user(new_user_username, self.get_random_string(length=8)) 50 | res = self.nxc.delete_user(new_user_username) 51 | assert res.is_ok 52 | user_res = self.nxc.get_user(new_user_username) 53 | assert user_res.status_code == self.NOT_FOUND_CODE 54 | 55 | def test_edit_user(self): 56 | new_user_username = self.create_new_user("edit_user") 57 | new_user_values = { 58 | "email": "test@test.test", 59 | # "quota": "100MB", FIXME: only admins (of a group?) can edit quota 60 | "phone": "+380999999999", 61 | "address": "World!", 62 | "website": "example.com", 63 | "twitter": "@twitter_account", 64 | "displayname": "test user!", 65 | "password": self.get_random_string(length=10) 66 | } 67 | for key, value in new_user_values.items(): 68 | res = self.nxc.edit_user(new_user_username, key, value) 69 | assert res.is_ok 70 | 71 | def test_resend_welcome_mail(self): 72 | # TODO: add mock of SMTP data to test this method 73 | pass 74 | 75 | 76 | class TestUserGroups(BaseTestCase): 77 | 78 | def setUp(self): 79 | super(TestUserGroups, self).setUp() 80 | self.user_username = self.create_new_user('user_group_') 81 | # create group 82 | self.group_name = self.get_random_string(length=4) + "_user_groups" 83 | self.nxc.add_group(self.group_name) 84 | 85 | def tearDown(self): 86 | self.delete_user(self.user_username) 87 | self.nxc.delete_group(self.group_name) 88 | 89 | def test_add_remove_user_from_group(self): 90 | # add to group 91 | res = self.nxc.add_to_group(self.user_username, self.group_name) 92 | assert res.is_ok 93 | group_members = self.nxc.get_group(self.group_name).data['users'] 94 | assert self.user_username in group_members 95 | # remove from group 96 | res = self.nxc.remove_from_group(self.user_username, self.group_name) 97 | assert res.is_ok 98 | group_members = self.nxc.get_group(self.group_name).data['users'] 99 | assert self.user_username not in group_members 100 | 101 | def test_create_retrieve_delete_subadmin(self): 102 | # promote to subadmin 103 | res = self.nxc.create_subadmin(self.user_username, self.group_name) 104 | assert res.is_ok 105 | # get subadmin groups 106 | subadmin_groups = self.nxc.get_subadmin_groups(self.user_username) 107 | assert subadmin_groups.is_ok 108 | assert self.group_name in subadmin_groups.data 109 | # demote from subadmin 110 | res = self.nxc.remove_subadmin(self.user_username, self.group_name) 111 | assert res.is_ok 112 | subadmin_groups = self.nxc.get_subadmin_groups(self.user_username).data 113 | assert self.group_name not in subadmin_groups 114 | -------------------------------------------------------------------------------- /src/nextcloud/api_wrappers/user.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from nextcloud.base import WithRequester 3 | 4 | 5 | class User(WithRequester): 6 | API_URL = "/ocs/v1.php/cloud/users" 7 | SUCCESS_CODE = 100 8 | 9 | def add_user(self, uid, passwd): 10 | """ 11 | Create a new user on the Nextcloud server 12 | 13 | :param uid: str, uid of new user 14 | :param passwd: str, password of new user 15 | :return: 16 | """ 17 | msg = {'userid': uid, 'password': passwd} 18 | return self.requester.post("", msg) 19 | 20 | def get_users(self, search=None, limit=None, offset=None): 21 | """ 22 | Retrieve a list of users from the Nextcloud server 23 | 24 | :param search: string, optional search string 25 | :param limit: int, optional limit value 26 | :param offset: int, optional offset value 27 | :return: 28 | """ 29 | params = { 30 | 'search': search, 31 | 'limit': limit, 32 | 'offset': offset 33 | } 34 | return self.requester.get(params=params) 35 | 36 | def get_user(self, uid): 37 | """ 38 | Retrieve information about a single user 39 | 40 | :param uid: str, uid of user 41 | :return: 42 | """ 43 | return self.requester.get("{uid}".format(uid=uid)) 44 | 45 | def edit_user(self, uid, what, value): 46 | """ 47 | Edit attributes related to a user 48 | 49 | Users are able to edit email, displayname and password; admins can also edit the 50 | quota value 51 | 52 | :param uid: str, uid of user 53 | :param what: str, the field to edit 54 | :param value: str, the new value for the field 55 | :return: 56 | """ 57 | what_to_key_map = dict( 58 | email="email", quota="quota", phone="phone", address="address", website="website", 59 | twitter="twitter", displayname="displayname", password="password", 60 | ) 61 | assert what in what_to_key_map, ( 62 | "You have chosen to edit user's '{what}', but you can choose only from: {choices}" 63 | .format(what=what, choices=", ".join(what_to_key_map.keys())) 64 | ) 65 | 66 | url = "{uid}".format(uid=uid) 67 | msg = dict( 68 | key=what_to_key_map[what], 69 | value=value, 70 | ) 71 | return self.requester.put(url, msg) 72 | 73 | def disable_user(self, uid): 74 | """ 75 | Disable a user on the Nextcloud server so that the user cannot login anymore 76 | 77 | :param uid: str, uid of user 78 | :return: 79 | """ 80 | return self.requester.put("{uid}/disable".format(uid=uid)) 81 | 82 | def enable_user(self, uid): 83 | """ 84 | Enable a user on the Nextcloud server so that the user can login again 85 | 86 | :param uid: str, uid of user 87 | :return: 88 | """ 89 | return self.requester.put("{uid}/enable".format(uid=uid)) 90 | 91 | def delete_user(self, uid): 92 | """ 93 | Delete a user from the Nextcloud server 94 | 95 | :param uid: str, uid of user 96 | :return: 97 | """ 98 | return self.requester.delete("{uid}".format(uid=uid)) 99 | 100 | def add_to_group(self, uid, gid): 101 | """ 102 | Add the specified user to the specified group 103 | 104 | :param uid: str, uid of user 105 | :param gid: str, name of group 106 | :return: 107 | """ 108 | url = "{uid}/groups".format(uid=uid) 109 | msg = {'groupid': gid} 110 | return self.requester.post(url, msg) 111 | 112 | def remove_from_group(self, uid, gid): 113 | """ 114 | Remove the specified user from the specified group 115 | 116 | :param uid: str, uid of user 117 | :param gid: str, name of group 118 | :return: 119 | """ 120 | url = "{uid}/groups".format(uid=uid) 121 | msg = {'groupid': gid} 122 | return self.requester.delete(url, msg) 123 | 124 | def create_subadmin(self, uid, gid): 125 | """ 126 | Make a user the subadmin of a group 127 | 128 | :param uid: str, uid of user 129 | :param gid: str, name of group 130 | :return: 131 | """ 132 | url = "{uid}/subadmins".format(uid=uid) 133 | msg = {'groupid': gid} 134 | return self.requester.post(url, msg) 135 | 136 | def remove_subadmin(self, uid, gid): 137 | """ 138 | Remove the subadmin rights for the user specified from the group specified 139 | 140 | :param uid: str, uid of user 141 | :param gid: str, name of group 142 | :return: 143 | """ 144 | url = "{uid}/subadmins".format(uid=uid) 145 | msg = {'groupid': gid} 146 | return self.requester.delete(url, msg) 147 | 148 | def get_subadmin_groups(self, uid): 149 | """ 150 | Get the groups in which the user is a subadmin 151 | 152 | :param uid: str, uid of user 153 | :return: 154 | """ 155 | url = "{uid}/subadmins".format(uid=uid) 156 | return self.requester.get(url) 157 | 158 | def resend_welcome_mail(self, uid): 159 | """ 160 | Trigger the welcome email for this user again 161 | 162 | :param uid: str, uid of user 163 | :return: 164 | """ 165 | url = "{uid}/welcome".format(uid=uid) 166 | return self.requester.post(url) 167 | -------------------------------------------------------------------------------- /tests/test_group_folders.py: -------------------------------------------------------------------------------- 1 | from nextcloud.base import Permission, QUOTA_UNLIMITED 2 | 3 | from .base import BaseTestCase 4 | 5 | 6 | class TestGroupFolders(BaseTestCase): 7 | 8 | def setUp(self): 9 | super(TestGroupFolders, self).setUp() 10 | self.nxc.enable_app('groupfolders') 11 | 12 | def test_crud_group_folder(self): 13 | # create new group folder 14 | folder_mount_point = "test_group_folders_" + self.get_random_string(length=4) 15 | res = self.nxc.create_group_folder(folder_mount_point) 16 | assert res.is_ok 17 | group_folder_id = res.data['id'] 18 | 19 | # get all group folders and check if just created folder is in the list 20 | res = self.nxc.get_group_folders() 21 | assert res.is_ok 22 | group_folders = res.data 23 | assert str(group_folder_id) in group_folders 24 | assert group_folders[str(group_folder_id)]['mount_point'] == folder_mount_point 25 | 26 | # retrieve single folder 27 | res = self.nxc.get_group_folder(group_folder_id) 28 | assert res.is_ok 29 | assert str(res.data['id']) == str(group_folder_id) 30 | assert res.data['mount_point'] == folder_mount_point 31 | 32 | # rename group folder 33 | new_folder_mount_point = folder_mount_point + '_new' 34 | res = self.nxc.rename_group_folder(group_folder_id, new_folder_mount_point) 35 | assert res.is_ok and res.data is True 36 | # check if name was changed 37 | res = self.nxc.get_group_folder(group_folder_id) 38 | assert res.data['mount_point'] == new_folder_mount_point 39 | 40 | # delete group folder 41 | res = self.nxc.delete_group_folder(group_folder_id) 42 | assert res.is_ok 43 | assert res.data is True 44 | # check that deleted folder isn't retrieved 45 | res = self.nxc.get_group_folder(group_folder_id) 46 | assert res.is_ok 47 | assert res.data is False 48 | 49 | def test_grant_revoke_access_to_group_folder(self): 50 | # create group to share with 51 | group_id = 'test_folders_' + self.get_random_string(length=4) 52 | self.nxc.add_group(group_id) 53 | 54 | # create new group folder 55 | folder_mount_point = "test_access_" + self.get_random_string(length=4) 56 | res = self.nxc.create_group_folder(folder_mount_point) 57 | group_folder_id = res.data['id'] 58 | 59 | # assert that no groups have access to just created folder 60 | res = self.nxc.get_group_folder(group_folder_id) 61 | assert len(res.data['groups']) == 0 62 | 63 | # grant access to group 64 | res = self.nxc.grant_access_to_group_folder(group_folder_id, group_id) 65 | assert res.is_ok 66 | assert res.data is True 67 | 68 | # check that folder has this group 69 | res = self.nxc.get_group_folder(group_folder_id) 70 | assert res.data['groups'] == {group_id: Permission.ALL.value} 71 | 72 | # revoke access 73 | res = self.nxc.revoke_access_to_group_folder(group_folder_id, group_id) 74 | assert res.is_ok 75 | assert res.data is True 76 | 77 | # check that folder has no groups again 78 | res = self.nxc.get_group_folder(group_folder_id) 79 | assert len(res.data['groups']) == 0 80 | 81 | # clear 82 | self.clear(nxc=self.nxc, group_ids=[group_id], group_folder_ids=[group_folder_id]) 83 | 84 | def test_setting_folder_quotas(self): 85 | # create new group folder 86 | folder_mount_point = "test_folder_quotas_" + self.get_random_string(length=4) 87 | res = self.nxc.create_group_folder(folder_mount_point) 88 | group_folder_id = res.data['id'] 89 | 90 | # assert quota is unlimited by default 91 | res = self.nxc.get_group_folder(group_folder_id) 92 | assert int(res.data['quota']) == QUOTA_UNLIMITED 93 | 94 | # set quota 95 | QUOTA_ONE_GB = 1024 * 1024 * 1024 96 | res = self.nxc.set_quota_of_group_folder(group_folder_id, QUOTA_ONE_GB) 97 | assert res.is_ok and res.data is True 98 | # check if quota changed 99 | res = self.nxc.get_group_folder(group_folder_id) 100 | assert str(res.data['quota']) == str(QUOTA_ONE_GB) 101 | 102 | # clear 103 | self.clear(group_folder_ids=[group_folder_id]) 104 | 105 | def test_setting_folder_permissions(self): 106 | # create group to share with 107 | group_id = 'test_folders_' + self.get_random_string(length=4) 108 | self.nxc.add_group(group_id) 109 | 110 | # create new group folder 111 | folder_mount_point = "test_folder_permissions_" + self.get_random_string(length=4) 112 | res = self.nxc.create_group_folder(folder_mount_point) 113 | group_folder_id = res.data['id'] 114 | 115 | # add new group to folder 116 | self.nxc.grant_access_to_group_folder(group_folder_id, group_id) 117 | # assert permissions is ALL by default 118 | res = self.nxc.get_group_folder(group_folder_id) 119 | assert int(res.data['quota']) == QUOTA_UNLIMITED 120 | 121 | # set permissions 122 | new_permission = Permission.READ + Permission.CREATE 123 | res = self.nxc.set_permissions_to_group_folder(group_folder_id, group_id, new_permission) 124 | assert res.is_ok 125 | assert res.data is True 126 | # check if permissions changed 127 | res = self.nxc.get_group_folder(group_folder_id) 128 | assert str(res.data['groups'][group_id]) == str(new_permission) 129 | 130 | # clear 131 | self.clear(nxc=self.nxc, group_ids=[group_id], group_folder_ids=[group_folder_id]) 132 | -------------------------------------------------------------------------------- /src/nextcloud/api_wrappers/share.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from nextcloud.base import WithRequester, ShareType 3 | 4 | 5 | class Share(WithRequester): 6 | API_URL = "/ocs/v2.php/apps/files_sharing/api/v1" 7 | LOCAL = "shares" 8 | SUCCESS_CODE = 200 9 | 10 | def get_local_url(self, additional_url=""): 11 | if additional_url: 12 | return "/".join([self.LOCAL, additional_url]) 13 | return self.LOCAL 14 | 15 | @staticmethod 16 | def validate_share_parameters(path, share_type, share_with): 17 | """ 18 | Check if share parameters make sense 19 | 20 | Args: 21 | path (str): path to the file/folder which should be shared 22 | share_type (int): ShareType attribute 23 | share_with (str): user/group id with which the file should be shared 24 | 25 | Returns: 26 | bool: True if parameters make sense together, False otherwise 27 | """ 28 | if ( 29 | path is None or not isinstance(share_type, int) or ( 30 | share_with is None and 31 | share_type in [ShareType.GROUP, ShareType.USER, ShareType.FEDERATED_CLOUD_SHARE] 32 | ) 33 | ): 34 | return False 35 | return True 36 | 37 | def get_shares(self): 38 | """ Get all shares from the user """ 39 | return self.requester.get(self.get_local_url()) 40 | 41 | def get_shares_from_path(self, path, reshares=None, subfiles=None): 42 | """ 43 | Get all shares from a given file/folder 44 | 45 | Args: 46 | path (str): path to file/folder 47 | reshares (bool): (optional) return not only the shares from the current user but 48 | all shares from the given file 49 | subfiles (bool): (optional) return all shares within a folder, given that path 50 | defines a folder 51 | 52 | Returns: 53 | 54 | """ 55 | url = self.get_local_url() 56 | params = { 57 | "path": path, 58 | # TODO: test reshares, subfiles 59 | "reshares": None if reshares is None else str(bool(reshares)).lower(), 60 | "subfiles": None if subfiles is None else str(bool(subfiles)).lower(), 61 | } 62 | return self.requester.get(url, params=params) 63 | 64 | def get_share_info(self, sid): 65 | """ 66 | Get information about a given share 67 | 68 | Args: 69 | sid (int): share id 70 | 71 | Returns: 72 | """ 73 | return self.requester.get(self.get_local_url(sid)) 74 | 75 | def create_share( 76 | self, path, share_type, share_with=None, public_upload=None, 77 | password=None, permissions=None): 78 | """ 79 | Share a file/folder with a user/group or as public link 80 | 81 | Mandatory fields: share_type, path and share_with for share_type USER (0) or GROUP (1). 82 | 83 | Args: 84 | path (str): path to the file/folder which should be shared 85 | share_type (int): ShareType attribute 86 | share_with (str): user/group id with which the file should be shared 87 | public_upload (bool): bool, allow public upload to a public shared folder (true/false) 88 | password (str): password to protect public link Share with 89 | permissions (int): sum of selected Permission attributes 90 | 91 | Returns: 92 | 93 | """ 94 | if not self.validate_share_parameters(path, share_type, share_with): 95 | return False 96 | 97 | url = self.get_local_url() 98 | if public_upload: 99 | public_upload = "true" 100 | 101 | data = {"path": path, "shareType": share_type} 102 | if share_type in [ShareType.GROUP, ShareType.USER, ShareType.FEDERATED_CLOUD_SHARE]: 103 | data["shareWith"] = share_with 104 | if public_upload: 105 | data["publicUpload"] = public_upload 106 | if share_type == ShareType.PUBLIC_LINK and password is not None: 107 | data["password"] = str(password) 108 | if permissions is not None: 109 | data["permissions"] = permissions 110 | return self.requester.post(url, data) 111 | 112 | def delete_share(self, sid): 113 | """ 114 | Remove the given share 115 | 116 | Args: 117 | sid (str): share id 118 | 119 | Returns: 120 | 121 | """ 122 | return self.requester.delete(self.get_local_url(sid)) 123 | 124 | def update_share(self, sid, 125 | permissions=None, password=None, public_upload=None, expire_date=""): 126 | """ 127 | Update a given share, only one value can be updated per request 128 | 129 | Args: 130 | sid (str): share id 131 | permissions (int): sum of selected Permission attributes 132 | password (str): password to protect public link Share with 133 | public_upload (bool): bool, allow public upload to a public shared folder (true/false) 134 | expire_date (str): set an expire date for public link shares. Format: ‘YYYY-MM-DD’ 135 | 136 | Returns: 137 | 138 | """ 139 | params = dict( 140 | permissions=permissions, 141 | password=password, 142 | expireDate=expire_date 143 | ) 144 | if public_upload: 145 | params["publicUpload"] = "true" 146 | if public_upload is False: 147 | params["publicUpload"] = "false" 148 | 149 | # check if only one param specified 150 | specified_params_count = sum([int(bool(each)) for each in params.values()]) 151 | if specified_params_count > 1: 152 | raise ValueError("Only one parameter for update can be specified per request") 153 | 154 | url = self.get_local_url(sid) 155 | return self.requester.put(url, data=params) 156 | -------------------------------------------------------------------------------- /src/nextcloud/api_wrappers/user_ldap.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | 4 | from nextcloud.base import WithRequester 5 | 6 | 7 | class UserLDAP(WithRequester): 8 | API_URL = "/ocs/v2.php/apps/user_ldap/api/v1/config" 9 | SUCCESS_CODE = 200 10 | 11 | CONFIG_KEYS = [ 12 | "ldapHost", 13 | "ldapPort", 14 | "ldapBackupHost", 15 | "ldapBackupPort", 16 | "ldapBase", 17 | "ldapBaseUsers", 18 | "ldapBaseGroups", 19 | "ldapAgentName", 20 | "ldapAgentPassword", 21 | "ldapTLS", 22 | "turnOffCertCheck", 23 | "ldapUserDisplayName", 24 | "ldapGidNumber", 25 | "ldapUserFilterObjectclass", 26 | "ldapUserFilterGroups", 27 | "ldapUserFilter", 28 | "ldapUserFilterMode", 29 | "ldapGroupFilter", 30 | "ldapGroupFilterMode", 31 | "ldapGroupFilterObjectclass", 32 | "ldapGroupFilterGroups", 33 | "ldapGroupMemberAssocAttr", 34 | "ldapGroupDisplayName", 35 | "ldapLoginFilter", 36 | "ldapLoginFilterMode", 37 | "ldapLoginFilterEmail", 38 | "ldapLoginFilterUsername", 39 | "ldapLoginFilterAttributes", 40 | "ldapQuotaAttribute", 41 | "ldapQuotaDefault", 42 | "ldapEmailAttribute", 43 | "ldapCacheTTL", 44 | "ldapUuidUserAttribute", 45 | "ldapUuidGroupAttribute", 46 | "ldapOverrideMainServer", 47 | "ldapConfigurationActive", 48 | "ldapAttributesForUserSearch", 49 | "ldapAttributesForGroupSearch", 50 | "ldapExperiencedAdmin", 51 | "homeFolderNamingRule", 52 | "hasMemberOfFilterSupport", 53 | "useMemberOfToDetectMembership", 54 | "ldapExpertUsernameAttr", 55 | "ldapExpertUUIDUserAttr", 56 | "ldapExpertUUIDGroupAttr", 57 | "lastJpegPhotoLookup", 58 | "ldapNestedGroups", 59 | "ldapPagingSize", 60 | "turnOnPasswordChange", 61 | "ldapDynamicGroupMemberURL", 62 | "ldapDefaultPPolicyDN", 63 | ] 64 | 65 | def create_ldap_config(self): 66 | """ Create a new and empty LDAP configuration """ 67 | return self.requester.post() 68 | 69 | def get_ldap_config_id(self, idx=1): 70 | """ 71 | The LDAP config ID is a string. 72 | Given the number of the config file, return the corresponding string ID 73 | if the configuration exists. 74 | 75 | Args: 76 | idx: The index of the configuration. 77 | If a single configuration exists on the server from the beginning, 78 | it is going to have index of 1. 79 | 80 | Returns: 81 | Configuration string or None 82 | """ 83 | config_id = f"s{idx:02d}" 84 | config = self.get_ldap_config(config_id) 85 | if config.is_ok: 86 | return config_id 87 | return None 88 | 89 | def get_ldap_lowest_existing_config_id(self, lower_bound=1, upper_bound=10): 90 | """ 91 | Given (inclusive) lower and upper bounds, try to guess an existing LDAP config ID 92 | that corresponds to an index within those bounds. 93 | 94 | Args: 95 | lower_bound: The lowest index of the configuration possible. 96 | upper_bound: The greatest index of the configuration possible. 97 | 98 | Returns: 99 | Configuration string or None 100 | """ 101 | for idx in range(lower_bound, upper_bound + 1): 102 | config_id = self.get_ldap_config_id(idx) 103 | if config_id: 104 | return config_id 105 | 106 | def get_ldap_config(self, config_id, show_password=None): 107 | """ 108 | Get all keys and values of the specified LDAP configuration 109 | 110 | Args: 111 | config_id (str): User LDAP config id 112 | show_password (int): 0 or 1 whether to return the password in clear text (default 0) 113 | 114 | Returns: 115 | 116 | """ 117 | params = dict(showPassword=show_password) 118 | return self.requester.get(config_id, params=params) 119 | 120 | def edit_ldap_config(self, config_id, data): 121 | """ 122 | Update a configuration with the provided values 123 | 124 | You can find list of all config keys in get_ldap_config method response or in 125 | Nextcloud docs 126 | 127 | Args: 128 | config_id (str): User LDAP config id 129 | data (dict): config values to update 130 | 131 | Returns: 132 | 133 | """ 134 | prepared_data = {'configData[{}]'.format(key): value for key, value in data.items()} 135 | return self.requester.put(config_id, data=prepared_data) 136 | 137 | def ldap_cache_flush(self, config_id): 138 | """ 139 | Flush the cache, so the fresh LDAP DB data is used. 140 | 141 | Implementation detail: 142 | This is performed by a fake update of LDAP cache TTL 143 | as indicated by 144 | 145 | Args: 146 | config_id (str): User LDAP config id 147 | """ 148 | cache_val = self.get_ldap_cache_ttl(config_id) 149 | self.set_ldap_cache_ttl(config_id, cache_val) 150 | 151 | def delete_ldap_config(self, config_id): 152 | """ 153 | Delete a given LDAP configuration 154 | 155 | Args: 156 | config_id (str): User LDAP config id 157 | 158 | Returns: 159 | 160 | """ 161 | return self.requester.delete(config_id) 162 | 163 | 164 | for ldap_key in UserLDAP.CONFIG_KEYS: 165 | key_name = re.sub('ldap', '', ldap_key) 166 | key_name = re.sub('([a-z0-9])([A-Z])', r'\1_\2', key_name).lower() 167 | 168 | # create and add getter method 169 | getter_name = "get_ldap_{}".format(key_name) 170 | 171 | def getter_method(param): 172 | def getter(self, config_id): 173 | res = self.get_ldap_config(config_id) 174 | data = res.data 175 | return data[param] 176 | getter.__name__ = getter_name 177 | return getter 178 | 179 | setattr(UserLDAP, getter_name, getter_method(ldap_key)) 180 | 181 | # create and add setter method 182 | setter_name = "set_ldap_{}".format(key_name) 183 | 184 | def setter_method(param): 185 | def setter(self, config_id, value): 186 | res = self.edit_ldap_config(config_id, data={param: value}) 187 | return res 188 | setter.__name__ = setter_name 189 | return setter 190 | 191 | setattr(UserLDAP, setter_name, setter_method(ldap_key)) 192 | -------------------------------------------------------------------------------- /src/nextcloud/requester.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import requests 3 | from functools import wraps 4 | 5 | from .response import WebDAVResponse, OCSResponse 6 | 7 | 8 | class NextCloudConnectionError(Exception): 9 | """ A connection error occurred """ 10 | 11 | 12 | def catch_connection_error(func): 13 | @wraps(func) 14 | def wrapper(*args, **kwargs): 15 | try: 16 | return func(*args, **kwargs) 17 | except requests.RequestException as e: 18 | raise NextCloudConnectionError("Failed to establish connection to NextCloud", 19 | getattr(e.request, 'url', None), e) 20 | return wrapper 21 | 22 | 23 | class Requester(object): 24 | def __init__(self, endpoint, user, passwd, json_output=False): 25 | self.query_components = [] 26 | 27 | self.json_output = json_output 28 | 29 | self.base_url = endpoint 30 | 31 | self.h_get = {"OCS-APIRequest": "true"} 32 | self.h_post = {"OCS-APIRequest": "true", 33 | "Content-Type": "application/x-www-form-urlencoded"} 34 | self.auth_pk = (user, passwd) 35 | self.API_URL = None 36 | self.SUCCESS_CODE = None 37 | 38 | def rtn(self, resp): 39 | if self.json_output: 40 | return resp.json() 41 | else: 42 | return resp.content.decode("UTF-8") 43 | 44 | @catch_connection_error 45 | def get(self, url="", params=None): 46 | url = self.get_full_url(url) 47 | res = requests.get(url, auth=self.auth_pk, headers=self.h_get, params=params) 48 | return self.rtn(res) 49 | 50 | @catch_connection_error 51 | def post(self, url="", data=None): 52 | url = self.get_full_url(url) 53 | res = requests.post(url, auth=self.auth_pk, data=data, headers=self.h_post) 54 | return self.rtn(res) 55 | 56 | @catch_connection_error 57 | def put_with_timestamp(self, url="", data=None, timestamp=None): 58 | h_post = self.h_post 59 | if isinstance(timestamp, (float, int)): 60 | h_post["X-OC-MTIME"] = f"{timestamp:.0f}" 61 | url = self.get_full_url(url) 62 | res = requests.put(url, auth=self.auth_pk, data=data, headers=h_post) 63 | return self.rtn(res) 64 | 65 | @catch_connection_error 66 | def put(self, url="", data=None): 67 | url = self.get_full_url(url) 68 | res = requests.put(url, auth=self.auth_pk, data=data, headers=self.h_post) 69 | return self.rtn(res) 70 | 71 | @catch_connection_error 72 | def delete(self, url="", data=None): 73 | url = self.get_full_url(url) 74 | res = requests.delete(url, auth=self.auth_pk, data=data, headers=self.h_post) 75 | return self.rtn(res) 76 | 77 | def get_full_url(self, additional_url=""): 78 | """ 79 | Build full url for request to NextCloud api 80 | 81 | Construct url from self.base_url, self.API_URL, additional_url (if given), 82 | add format=json param if self.json 83 | 84 | :param additional_url: str 85 | add to url after api_url 86 | :return: str 87 | """ 88 | if additional_url and not str(additional_url).startswith("/"): 89 | additional_url = "/{}".format(additional_url) 90 | 91 | if self.json_output: 92 | self.query_components.append("format=json") 93 | 94 | ret = "{base_url}{api_url}{additional_url}".format( 95 | base_url=self.base_url, api_url=self.API_URL, additional_url=additional_url) 96 | 97 | if self.json_output: 98 | ret += "?format=json" 99 | return ret 100 | 101 | 102 | class OCSRequester(Requester): 103 | """ Requester for OCS API """ 104 | 105 | def rtn(self, resp): 106 | return OCSResponse(response=resp, 107 | json_output=self.json_output, success_code=self.SUCCESS_CODE) 108 | 109 | 110 | class WebDAVRequester(Requester): 111 | """ Requester for WebDAV API """ 112 | 113 | def __init__(self, *args, **kwargs): 114 | super(WebDAVRequester, self).__init__(*args, **kwargs) 115 | 116 | def rtn(self, resp, data=None): 117 | return WebDAVResponse(response=resp, data=data) 118 | 119 | @catch_connection_error 120 | def propfind(self, additional_url="", headers=None, data=None): 121 | url = self.get_full_url(additional_url=additional_url) 122 | res = requests.request('PROPFIND', url, auth=self.auth_pk, headers=headers, data=data) 123 | return self.rtn(res) 124 | 125 | @catch_connection_error 126 | def proppatch(self, additional_url="", data=None): 127 | url = self.get_full_url(additional_url=additional_url) 128 | res = requests.request('PROPPATCH', url, auth=self.auth_pk, data=data) 129 | return self.rtn(resp=res) 130 | 131 | @catch_connection_error 132 | def report(self, additional_url="", data=None): 133 | url = self.get_full_url(additional_url=additional_url) 134 | res = requests.request('REPORT', url, auth=self.auth_pk, data=data) 135 | return self.rtn(resp=res) 136 | 137 | @catch_connection_error 138 | def download(self, url="", params=None): 139 | url = self.get_full_url(url) 140 | res = requests.get(url, auth=self.auth_pk, headers=self.h_get, params=params) 141 | return self.rtn(resp=res, data=res.content) 142 | 143 | @catch_connection_error 144 | def make_collection(self, additional_url=""): 145 | url = self.get_full_url(additional_url=additional_url) 146 | res = requests.request("MKCOL", url=url, auth=self.auth_pk) 147 | return self.rtn(resp=res) 148 | 149 | @catch_connection_error 150 | def move(self, url, destination, overwrite=False): 151 | url = self.get_full_url(additional_url=url) 152 | destination_url = self.get_full_url(additional_url=destination) 153 | headers = { 154 | "Destination": destination_url.encode('utf-8'), 155 | "Overwrite": "T" if overwrite else "F" 156 | } 157 | res = requests.request("MOVE", url=url, auth=self.auth_pk, headers=headers) 158 | return self.rtn(resp=res) 159 | 160 | @catch_connection_error 161 | def copy(self, url, destination, overwrite=False): 162 | url = self.get_full_url(additional_url=url) 163 | destination_url = self.get_full_url(additional_url=destination) 164 | headers = { 165 | "Destination": destination_url.encode('utf-8'), 166 | "Overwrite": "T" if overwrite else "F" 167 | } 168 | res = requests.request("COPY", url=url, auth=self.auth_pk, headers=headers) 169 | return self.rtn(resp=res) 170 | -------------------------------------------------------------------------------- /tests/test_shares.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from datetime import datetime, timedelta 3 | 4 | from nextcloud.base import ShareType, Permission, datetime_to_expire_date 5 | 6 | from .base import BaseTestCase, LocalNxcUserMixin 7 | 8 | 9 | class TestShares(LocalNxcUserMixin, BaseTestCase): 10 | 11 | def test_share_create_retrieve_delete(self): 12 | """ shallow test for base retrieving single, list, creating and deleting share """ 13 | # check no shares exists 14 | res = self.nxc_local.get_shares() 15 | assert res.is_ok 16 | assert len(res.data) == 0 17 | 18 | # create public share 19 | res = self.nxc_local.create_share('Documents', share_type=ShareType.PUBLIC_LINK.value) 20 | assert res.is_ok 21 | share_id = res.data['id'] 22 | 23 | # get all shares 24 | all_shares = self.nxc_local.get_shares().data 25 | assert len(all_shares) == 1 26 | assert all_shares[0]['id'] == share_id 27 | assert all_shares[0]['share_type'] == ShareType.PUBLIC_LINK.value 28 | 29 | # get single share info 30 | created_share = self.nxc_local.get_share_info(share_id) 31 | assert res.is_ok 32 | created_share_data = created_share.data[0] 33 | assert created_share_data['id'] == share_id 34 | assert created_share_data['share_type'] == ShareType.PUBLIC_LINK.value 35 | assert created_share_data['uid_owner'] == self.user_username 36 | 37 | # delete share 38 | res = self.nxc_local.delete_share(share_id) 39 | assert res.is_ok 40 | all_shares = self.nxc_local.get_shares().data 41 | assert len(all_shares) == 0 42 | 43 | def test_create(self): 44 | """ creating share with different params """ 45 | share_path = "Documents" 46 | user_to_share_with = self.create_new_user("test_shares_") 47 | group_to_share_with = 'group_to_share_with' 48 | self.nxc.add_group(group_to_share_with) 49 | 50 | # create for user, group 51 | for (share_type, share_with, permissions) in [(ShareType.USER.value, user_to_share_with, Permission.READ.value), 52 | (ShareType.GROUP.value, group_to_share_with, Permission.READ.value + Permission.CREATE.value)]: 53 | # create share with user 54 | res = self.nxc_local.create_share(share_path, 55 | share_type=share_type, 56 | share_with=share_with, 57 | permissions=permissions) 58 | assert res.is_ok 59 | share_id = res.data['id'] 60 | 61 | # check if shared with right user/group, permission 62 | created_share = self.nxc_local.get_share_info(share_id) 63 | assert res.is_ok 64 | created_share_data = created_share.data[0] 65 | assert created_share_data['id'] == share_id 66 | assert created_share_data['share_type'] == share_type 67 | assert created_share_data['share_with'] == share_with 68 | assert created_share_data['permissions'] == permissions 69 | 70 | # delete share, user 71 | self.nxc_local.delete_share(share_id) 72 | self.nxc.delete_user(user_to_share_with) 73 | 74 | def test_create_with_password(self): 75 | share_path = "Documents" 76 | res = self.nxc_local.create_share(path=share_path, 77 | share_type=ShareType.PUBLIC_LINK.value, 78 | password=self.get_random_string(length=8)) 79 | assert res.is_ok 80 | share_url = res.data['url'] 81 | share_resp = requests.get(share_url) 82 | assert "This share is password-protected" in share_resp.text 83 | self.nxc_local.delete_share(res.data['id']) 84 | 85 | def test_get_path_shares(self): 86 | share_path = "Documents" 87 | group_to_share_with_name = self.get_random_string(length=4) + "_test_add" 88 | self.nxc.add_group(group_to_share_with_name) 89 | 90 | # check that path has no shares yet 91 | res = self.nxc_local.get_shares_from_path(share_path) 92 | assert res.is_ok 93 | assert len(res.data) == 0 94 | 95 | # first path share 96 | first_share = self.nxc_local.create_share(path=share_path, 97 | share_type=ShareType.PUBLIC_LINK.value) 98 | 99 | # create second path share 100 | second_share = self.nxc_local.create_share(path=share_path, 101 | share_type=ShareType.GROUP.value, 102 | share_with=group_to_share_with_name, 103 | permissions=Permission.READ.value) 104 | 105 | all_shares_ids = [first_share.data['id'], second_share.data['id']] 106 | 107 | # check that path has two shares 108 | res = self.nxc_local.get_shares_from_path(share_path) 109 | assert res.is_ok 110 | assert len(res.data) == 2 111 | assert all([each['id'] in all_shares_ids for each in res.data]) 112 | 113 | # clean shares, groups 114 | self.clear(self.nxc_local, share_ids=all_shares_ids, group_ids=[group_to_share_with_name]) 115 | 116 | def test_update_share(self): 117 | share_path = "Documents" 118 | user_to_share_with = self.create_new_user("test_shares_") 119 | 120 | share_with = user_to_share_with 121 | share_type = ShareType.USER.value 122 | # create share with user 123 | res = self.nxc_local.create_share(share_path, 124 | share_type=ShareType.USER.value, 125 | share_with=user_to_share_with, 126 | permissions=Permission.READ.value) 127 | assert res.is_ok 128 | share_id = res.data['id'] 129 | 130 | # update share permissions 131 | new_permissions = Permission.READ.value + Permission.CREATE.value 132 | res = self.nxc_local.update_share(share_id, permissions=new_permissions) 133 | assert res.is_ok 134 | 135 | updated_share_data = res.data 136 | assert updated_share_data['id'] == share_id 137 | assert updated_share_data['share_type'] == share_type 138 | assert updated_share_data['share_with'] == share_with 139 | assert updated_share_data['permissions'] == new_permissions 140 | assert updated_share_data['expiration'] is None 141 | 142 | # update share expire date 143 | expire_date = datetime_to_expire_date(datetime.now() + timedelta(days=5)) 144 | res = self.nxc_local.update_share(share_id, expire_date=expire_date) 145 | assert res.is_ok 146 | 147 | updated_share_data = res.data 148 | assert updated_share_data['id'] == share_id 149 | assert updated_share_data['share_type'] == share_type 150 | assert updated_share_data['share_with'] == share_with 151 | assert updated_share_data['permissions'] == new_permissions 152 | assert updated_share_data['expiration'] == "{} 00:00:00".format(expire_date) 153 | 154 | self.clear(self.nxc_local, share_ids=[share_id], user_ids=[user_to_share_with]) 155 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/master/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import json 16 | import os 17 | import sys 18 | from datetime import date 19 | 20 | sys.path.insert(0, os.path.abspath('../../src')) 21 | 22 | 23 | # -- API implementation statuses ----------------------------------------------------- 24 | 25 | def make_rst_table_with_header(table_data): 26 | rst_table = "" 27 | 28 | page_title = table_data['title'] 29 | headers = table_data['headers'] 30 | headers_attributes = table_data['headers attributes'] 31 | data = table_data['data'] 32 | 33 | rst_table = "" 34 | 35 | # write page title 36 | rst_table += "{}\n{}\n".format(page_title, "-" * len(page_title)) 37 | 38 | table_data = [headers] 39 | table_links = [] 40 | column_lengths = [] 41 | 42 | # create list of lists with rows of data 43 | for data_row in data: 44 | row = [] 45 | for attr in headers_attributes: 46 | if attr == 'name' and 'url' in data_row.keys(): 47 | row.append("`{}`_".format(data_row[attr])) 48 | table_links.append(".. _{}: {}".format(data_row[attr], data_row['url'])) 49 | else: 50 | row.append(data_row[attr]) 51 | table_data += [row] 52 | 53 | # calculate max column length 54 | for column_num in range(len(table_data[0])): 55 | column_lengths.append(max([len(table_data[row_num][column_num]) for row_num in range(len(table_data))])) 56 | 57 | # add table borders 58 | table_data.insert(0, ["=" * column_lengths[i] for i in range(len(column_lengths))]) 59 | table_data.insert(2, ["=" * column_lengths[i] for i in range(len(column_lengths))]) 60 | 61 | # write rst table from table_data list of lists to string 62 | for row in table_data: 63 | for column_num in range(len(row)): 64 | rst_table += row[column_num] 65 | if column_num != len(row) - 1: 66 | rst_table += " " * (column_lengths[column_num] - len(row[column_num]) + 1) 67 | else: 68 | rst_table += '\n' 69 | 70 | # add links and border 71 | rst_table += " ".join(["=" * each for each in column_lengths]) + "\n\n" 72 | rst_table += '\n'.join(table_links) 73 | 74 | return rst_table 75 | 76 | 77 | with open("../../api_implementation.json", "r") as json_data, \ 78 | open('api_implementation.rst', 'w') as api_implementation: 79 | table_data = json.load(json_data) 80 | content = make_rst_table_with_header(table_data) 81 | api_implementation.write(content) 82 | 83 | # -- Project information ----------------------------------------------------- 84 | 85 | project = 'nextcloud-API' 86 | copyright = '2018, EnterpriseyIntranet' 87 | author = 'EnterpriseyIntranet' 88 | 89 | # The short X.Y version 90 | version = '' 91 | # The full version, including alpha/beta/rc tags 92 | release = '0.0.1' 93 | 94 | 95 | # -- General configuration --------------------------------------------------- 96 | 97 | # If your documentation needs a minimal Sphinx version, state it here. 98 | # 99 | # needs_sphinx = '1.0' 100 | 101 | # Add any Sphinx extension module names here, as strings. They can be 102 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 103 | # ones. 104 | extensions = [ 105 | 'sphinx.ext.doctest', 'sphinx.ext.autodoc', 'sphinx.ext.napoleon', 106 | ] 107 | 108 | # Add any paths that contain templates here, relative to this directory. 109 | templates_path = ['ntemplates'] 110 | 111 | # The suffix(es) of source filenames. 112 | # You can specify multiple suffix as a list of string: 113 | # 114 | # source_suffix = ['.rst', '.md'] 115 | source_suffix = '.rst' 116 | 117 | # The master toctree document. 118 | master_doc = 'index' 119 | 120 | # The language for content autogenerated by Sphinx. Refer to documentation 121 | # for a list of supported languages. 122 | # 123 | # This is also used if you do content translation via gettext catalogs. 124 | # Usually you set "language" from the command line for these cases. 125 | language = None 126 | 127 | # List of patterns, relative to source directory, that match files and 128 | # directories to ignore when looking for source files. 129 | # This pattern also affects html_static_path and html_extra_path. 130 | exclude_patterns = [] 131 | 132 | # The name of the Pygments (syntax highlighting) style to use. 133 | pygments_style = None 134 | 135 | 136 | # -- Options for HTML output ------------------------------------------------- 137 | 138 | # The theme to use for HTML and HTML Help pages. See the documentation for 139 | # a list of builtin themes. 140 | # 141 | html_theme = 'default' 142 | 143 | # Theme options are theme-specific and customize the look and feel of a theme 144 | # further. For a list of options available for each theme, see the 145 | # documentation. 146 | # 147 | # html_theme_options = {} 148 | 149 | # Add any paths that contain custom static files (such as style sheets) here, 150 | # relative to this directory. They are copied after the builtin static files, 151 | # so a file named "default.css" will overwrite the builtin "default.css". 152 | html_static_path = ['static'] 153 | 154 | # Custom sidebar templates, must be a dictionary that maps document names 155 | # to template names. 156 | # 157 | # The default sidebars (for documents that don't match any pattern) are 158 | # defined by theme itself. Builtin themes are using these templates by 159 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 160 | # 'searchbox.html']``. 161 | # 162 | # html_sidebars = {} 163 | 164 | 165 | # -- Options for HTMLHelp output --------------------------------------------- 166 | 167 | # Output file base name for HTML help builder. 168 | htmlhelp_basename = 'nextcloud-APIdoc' 169 | 170 | 171 | # -- Options for LaTeX output ------------------------------------------------ 172 | 173 | latex_elements = { 174 | # The paper size ('letterpaper' or 'a4paper'). 175 | # 176 | # 'papersize': 'letterpaper', 177 | 178 | # The font size ('10pt', '11pt' or '12pt'). 179 | # 180 | # 'pointsize': '10pt', 181 | 182 | # Additional stuff for the LaTeX preamble. 183 | # 184 | # 'preamble': '', 185 | 186 | # Latex figure (float) alignment 187 | # 188 | # 'figure_align': 'htbp', 189 | } 190 | 191 | # Grouping the document tree into LaTeX files. List of tuples 192 | # (source start file, target name, title, 193 | # author, documentclass [howto, manual, or own class]). 194 | latex_documents = [ 195 | (master_doc, 'nextcloud-API.tex', 'nextcloud-API Documentation', 196 | 'EnterpriseyIntranet', 'manual'), 197 | ] 198 | 199 | 200 | # -- Options for manual page output ------------------------------------------ 201 | 202 | # One entry per manual page. List of tuples 203 | # (source start file, name, description, authors, manual section). 204 | man_pages = [ 205 | (master_doc, 'nextcloud-api', 'nextcloud-API Documentation', 206 | [author], 1) 207 | ] 208 | 209 | 210 | # -- Options for Texinfo output ---------------------------------------------- 211 | 212 | # Grouping the document tree into Texinfo files. List of tuples 213 | # (source start file, target name, title, author, 214 | # dir menu entry, description, category) 215 | texinfo_documents = [ 216 | (master_doc, 'nextcloud-API', 'nextcloud-API Documentation', 217 | author, 'nextcloud-API', 'One line description of project.', 218 | 'Miscellaneous'), 219 | ] 220 | 221 | 222 | # -- Options for Epub output ------------------------------------------------- 223 | 224 | # Bibliographic Dublin Core info. 225 | epub_title = project 226 | 227 | # The unique identifier of the text. This can be a ISBN number 228 | # or the project homepage. 229 | # 230 | # epub_identifier = '' 231 | 232 | # A unique identification for the text. 233 | # 234 | # epub_uid = '' 235 | 236 | # A list of files that should not be packed into the epub file. 237 | epub_exclude_files = ['search.html'] 238 | 239 | 240 | # -- Extension configuration ------------------------------------------------- 241 | -------------------------------------------------------------------------------- /src/nextcloud/api_wrappers/webdav.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import re 3 | import os 4 | import pathlib 5 | 6 | import xml.etree.ElementTree as ET 7 | 8 | from datetime import datetime 9 | from nextcloud.base import WithRequester 10 | 11 | 12 | class WebDAV(WithRequester): 13 | 14 | API_URL = "/remote.php/dav/files" 15 | 16 | def __init__(self, *args, **kwargs): 17 | super(WebDAV, self).__init__(*args) 18 | self.json_output = kwargs.get('json_output') 19 | 20 | def list_folders(self, uid, path=None, depth=1, all_properties=False): 21 | """ 22 | Get path files list with files properties for given user, with given depth 23 | 24 | Args: 25 | uid (str): uid of user 26 | path (str/None): files path 27 | depth (int): depth of listing files (directories content for example) 28 | all_properties (bool): list all available file properties in Nextcloud 29 | 30 | Returns: 31 | list of dicts if json_output 32 | list of File objects if not json_output 33 | """ 34 | if all_properties: 35 | data = """ 36 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | """ 55 | else: 56 | data = None 57 | additional_url = uid 58 | if path: 59 | additional_url = "{}/{}".format(additional_url, path) 60 | resp = self.requester.propfind(additional_url=additional_url, 61 | headers={"Depth": str(depth)}, 62 | data=data) 63 | if not resp.is_ok: 64 | resp.data = None 65 | return resp 66 | response_data = resp.data 67 | response_xml_data = ET.fromstring(response_data) 68 | files_data = [File(single_file) for single_file in response_xml_data] 69 | resp.data = files_data if not self.json_output else [each.as_dict() for each in files_data] 70 | return resp 71 | 72 | def download_file(self, uid, path): 73 | """ 74 | Download file of given user by path 75 | File will be saved to working directory 76 | path argument must be valid file path 77 | Modified time of saved file will be synced with the file properties in Nextcloud 78 | 79 | Exception will be raised if: 80 | * path doesn't exist, 81 | * path is a directory, or if 82 | * file with same name already exists in working directory 83 | 84 | Args: 85 | uid (str): uid of user 86 | path (str): file path 87 | 88 | Returns: 89 | None 90 | """ 91 | additional_url = "/".join([uid, path]) 92 | filename = path.split('/')[-1] if '/' in path else path 93 | file_data = self.list_folders(uid=uid, path=path, depth=0) 94 | if not file_data: 95 | raise ValueError("Given path doesn't exist") 96 | file_resource_type = (file_data.data[0].get('resource_type') 97 | if self.json_output 98 | else file_data.data[0].resource_type) 99 | if file_resource_type == File.COLLECTION_RESOURCE_TYPE: 100 | raise ValueError("This is a collection, please specify file path") 101 | if filename in os.listdir('./'): 102 | raise ValueError("File with such name already exists in this directory") 103 | res = self.requester.download(additional_url) 104 | with open(filename, 'wb') as f: 105 | f.write(res.data) 106 | 107 | # get timestamp of downloaded file from file property on Nextcloud 108 | # If it succeeded, set the timestamp to saved local file 109 | # If the timestamp string is invalid or broken, the timestamp is downloaded time. 110 | file_timestamp_str = (file_data.data[0].get('last_modified') 111 | if self.json_output 112 | else file_data.data[0].last_modified) 113 | file_timestamp = timestamp_to_epoch_time(file_timestamp_str) 114 | if isinstance(file_timestamp, int): 115 | os.utime(filename, (datetime.now().timestamp(), file_timestamp)) 116 | 117 | def upload_file(self, uid, local_filepath, remote_filepath, timestamp=None): 118 | """ 119 | Upload file to Nextcloud storage 120 | 121 | Args: 122 | uid (str): uid of user 123 | local_filepath (str): path to file on local storage 124 | remote_filepath (str): path where to upload file on Nextcloud storage 125 | timestamp (int): timestamp of upload file. If None, get time by local file. 126 | """ 127 | with open(local_filepath, 'rb') as f: 128 | file_contents = f.read() 129 | if timestamp is None: 130 | timestamp = int(os.path.getmtime(local_filepath)) 131 | return self.upload_file_contents(uid, file_contents, remote_filepath, timestamp) 132 | 133 | def upload_file_contents(self, uid, file_contents, remote_filepath, timestamp=None): 134 | """ 135 | Upload file to Nextcloud storage 136 | 137 | Args: 138 | uid (str): uid of user 139 | file_contents (bytes): Bytes the file to be uploaded consists of 140 | remote_filepath (str): path where to upload file on Nextcloud storage 141 | timestamp (int): mtime of upload file 142 | """ 143 | additional_url = "/".join([uid, remote_filepath]) 144 | return self.requester.put_with_timestamp(additional_url, data=file_contents, timestamp=timestamp) 145 | 146 | def create_folder(self, uid, folder_path): 147 | """ 148 | Create folder on Nextcloud storage 149 | 150 | Args: 151 | uid (str): uid of user 152 | folder_path (str): folder path 153 | """ 154 | return self.requester.make_collection(additional_url="/".join([uid, folder_path])) 155 | 156 | def assure_folder_exists(self, uid, folder_path): 157 | """ 158 | Create folder on Nextcloud storage, don't do anything if the folder already exists. 159 | Args: 160 | uid (str): uid of user 161 | folder_path (str): folder path 162 | Returns: 163 | """ 164 | self.create_folder(uid, folder_path) 165 | return True 166 | 167 | def assure_tree_exists(self, uid, tree_path): 168 | """ 169 | Make sure that the folder structure on Nextcloud storage exists 170 | Args: 171 | uid (str): uid of user 172 | folder_path (str): The folder tree 173 | Returns: 174 | """ 175 | tree = pathlib.PurePath(tree_path) 176 | parents = list(tree.parents) 177 | ret = True 178 | subfolders = parents[:-1][::-1] + [tree] 179 | for subf in subfolders: 180 | ret = self.assure_folder_exists(uid, str(subf)) 181 | return ret 182 | 183 | def delete_path(self, uid, path): 184 | """ 185 | Delete file or folder with all content of given user by path 186 | 187 | Args: 188 | uid (str): uid of user 189 | path (str): file or folder path to delete 190 | """ 191 | url = "/".join([uid, path]) 192 | return self.requester.delete(url=url) 193 | 194 | def move_path(self, uid, path, destination_path, overwrite=False): 195 | """ 196 | Move file or folder to destination 197 | 198 | Args: 199 | uid (str): uid of user 200 | path (str): file or folder path to move 201 | destionation_path (str): destination where to move 202 | overwrite (bool): allow destination path overriding 203 | """ 204 | path_url = "/".join([uid, path]) 205 | destination_path_url = "/".join([uid, destination_path]) 206 | return self.requester.move(url=path_url, 207 | destination=destination_path_url, overwrite=overwrite) 208 | 209 | def copy_path(self, uid, path, destination_path, overwrite=False): 210 | """ 211 | Copy file or folder to destination 212 | 213 | Args: 214 | uid (str): uid of user 215 | path (str): file or folder path to copy 216 | destionation_path (str): destination where to copy 217 | overwrite (bool): allow destination path overriding 218 | """ 219 | path_url = "/".join([uid, path]) 220 | destination_path_url = "/".join([uid, destination_path]) 221 | return self.requester.copy(url=path_url, 222 | destination=destination_path_url, overwrite=overwrite) 223 | 224 | def set_favorites(self, uid, path): 225 | """ 226 | Set files of a user favorite 227 | 228 | Args: 229 | uid (str): uid of user 230 | path (str): file or folder path to make favorite 231 | """ 232 | data = """ 233 | 234 | 235 | 236 | 1 237 | 238 | 239 | 240 | """ 241 | url = "/".join([uid, path]) 242 | return self.requester.proppatch(additional_url=url, data=data) 243 | 244 | def list_favorites(self, uid, path=""): 245 | """ 246 | Set files of a user favorite 247 | 248 | Args: 249 | uid (str): uid of user 250 | path (str): file or folder path to make favorite 251 | """ 252 | data = """ 253 | 256 | 257 | 1 258 | 259 | 260 | """ 261 | url = "/".join([uid, path]) 262 | res = self.requester.report(additional_url=url, data=data) 263 | if not res.is_ok: 264 | res.data = None 265 | return res 266 | response_xml_data = ET.fromstring(res.data) 267 | files_data = [File(single_file) for single_file in response_xml_data] 268 | res.data = files_data if not self.json_output else [each.as_dict() for each in files_data] 269 | return res 270 | 271 | 272 | class File(object): 273 | SUCCESS_STATUS = 'HTTP/1.1 200 OK' 274 | 275 | # key is NextCloud property, value is python variable name 276 | FILE_PROPERTIES = { 277 | # d: 278 | "getlastmodified": "last_modified", 279 | "getetag": "etag", 280 | "getcontenttype": "content_type", 281 | "resourcetype": "resource_type", 282 | "getcontentlength": "content_length", 283 | # oc: 284 | "id": "id", 285 | "fileid": "file_id", 286 | "favorite": "favorite", 287 | "comments-href": "comments_href", 288 | "comments-count": "comments_count", 289 | "comments-unread": "comments_unread", 290 | "owner-id": "owner_id", 291 | "owner-display-name": "owner_display_name", 292 | "share-types": "share_types", 293 | "checksums": "check_sums", 294 | "size": "size", 295 | "href": "href", 296 | # nc: 297 | "has-preview": "has_preview", 298 | } 299 | xml_namespaces_map = { 300 | "d": "DAV:", 301 | "oc": "http://owncloud.org/ns", 302 | "nc": "http://nextcloud.org/ns" 303 | } 304 | COLLECTION_RESOURCE_TYPE = 'collection' 305 | 306 | def __init__(self, xml_data): 307 | self.href = xml_data.find('d:href', self.xml_namespaces_map).text 308 | for propstat in xml_data.iter('{DAV:}propstat'): 309 | if propstat.find('d:status', self.xml_namespaces_map).text != self.SUCCESS_STATUS: 310 | continue 311 | for file_property in propstat.find('d:prop', self.xml_namespaces_map): 312 | file_property_name = re.sub("{.*}", "", file_property.tag) 313 | if file_property_name not in self.FILE_PROPERTIES: 314 | continue 315 | if file_property_name == 'resourcetype': 316 | value = self._extract_resource_type(file_property) 317 | else: 318 | value = file_property.text 319 | setattr(self, self.FILE_PROPERTIES[file_property_name], value) 320 | 321 | def _extract_resource_type(self, file_property): 322 | file_type = list(file_property) 323 | if file_type: 324 | return re.sub("{.*}", "", file_type[0].tag) 325 | return None 326 | 327 | def as_dict(self): 328 | return {key: value 329 | for key, value in self.__dict__.items() 330 | if key in self.FILE_PROPERTIES.values()} 331 | 332 | 333 | class WebDAVStatusCodes(object): 334 | CREATED_CODE = 201 335 | NO_CONTENT_CODE = 204 336 | MULTISTATUS_CODE = 207 337 | ALREADY_EXISTS_CODE = 405 338 | PRECONDITION_FAILED_CODE = 412 339 | 340 | 341 | def timestamp_to_epoch_time(rfc1123_date=""): 342 | """ 343 | literal date time string (use in DAV:getlastmodified) to Epoch time 344 | 345 | No longer, Only rfc1123-date productions are legal as values for DAV:getlastmodified 346 | However, the value may be broken or invalid. 347 | 348 | Args: 349 | rfc1123_date (str): rfc1123-date (defined in RFC2616) 350 | Return: 351 | int or None : Epoch time, if date string value is invalid return None 352 | """ 353 | try: 354 | epoch_time = datetime.strptime(rfc1123_date, '%a, %d %b %Y %H:%M:%S GMT').timestamp() 355 | except ValueError: 356 | # validation error (DAV:getlastmodified property is broken or invalid) 357 | return None 358 | return int(epoch_time) 359 | -------------------------------------------------------------------------------- /tests/test_webdav.py: -------------------------------------------------------------------------------- 1 | import os 2 | from requests.utils import quote 3 | from datetime import datetime 4 | 5 | from .base import BaseTestCase, LocalNxcUserMixin 6 | from nextcloud.api_wrappers import WebDAV 7 | from nextcloud.api_wrappers.webdav import timestamp_to_epoch_time 8 | 9 | 10 | class TestWebDAV(LocalNxcUserMixin, BaseTestCase): 11 | 12 | CREATED_CODE = 201 13 | NO_CONTENT_CODE = 204 14 | MULTISTATUS_CODE = 207 15 | ALREADY_EXISTS_CODE = 405 16 | PRECONDITION_FAILED_CODE = 412 17 | 18 | COLLECTION_TYPE = 'collection' 19 | 20 | def create_and_upload_file(self, file_name, file_content, timestamp=None): 21 | with open(file_name, "w") as f: 22 | f.write(file_content) 23 | file_local_path = os.path.abspath(file_name) 24 | return self.nxc_local.upload_file(self.user_username, file_local_path, file_name, timestamp) 25 | 26 | def test_list_folders(self): 27 | res = self.nxc_local.list_folders(self.user_username) 28 | assert res.is_ok 29 | assert isinstance(res.data, list) 30 | assert isinstance(res.data[0], dict) 31 | res = self.nxc_local.list_folders(self.user_username, all_properties=True) 32 | assert res.is_ok 33 | assert isinstance(res.data, list) 34 | assert isinstance(res.data[0], dict) 35 | 36 | def test_upload_download_file(self): 37 | file_name = "test_file" 38 | file_content = "test file content" 39 | file_local_path = os.path.join(os.getcwd(), file_name) 40 | res = self.create_and_upload_file(file_name, file_content) 41 | # check status code 42 | assert res.is_ok 43 | assert res.raw.status_code == self.CREATED_CODE 44 | 45 | # test uploaded file can be found with list_folders 46 | file_nextcloud_href = os.path.join(WebDAV.API_URL, self.user_username, file_name) 47 | folder_info = self.nxc_local.list_folders(self.user_username, path=file_name) 48 | assert folder_info.is_ok 49 | assert len(folder_info.data) == 1 50 | assert isinstance(folder_info.data[0], dict) 51 | # check href 52 | assert folder_info.data[0]['href'] == file_nextcloud_href 53 | 54 | # remove file on local machine 55 | os.remove(file_local_path) 56 | self.nxc_local.download_file(self.user_username, file_name) 57 | # test file is downloaded to current dir 58 | assert file_name in os.listdir(".") 59 | with open(file_local_path, 'r') as f: 60 | downloaded_file_content = f.read() 61 | assert downloaded_file_content == file_content 62 | 63 | # delete file 64 | self.nxc_local.delete_path(self.user_username, file_name) 65 | os.remove(file_local_path) 66 | 67 | def test_upload_download_file_with_timestamp(self): 68 | file_name = "test_file_1579520460" 69 | file_content = "Test file: Mon, 20 Jan 2020 20:41:00 GMT" 70 | file_local_path = os.path.join(os.getcwd(), file_name) 71 | 72 | # create test file, and upload it as timestamp: Mon, 20 Jan 2020 20:41:00 GMT 73 | timestamp_str = "Mon, 20 Jan 2020 20:41:00 GMT" 74 | timestamp = timestamp_to_epoch_time(timestamp_str) 75 | assert timestamp == 1579552860 76 | res = self.create_and_upload_file(file_name, file_content, timestamp) 77 | 78 | # check status code 79 | assert res.is_ok 80 | assert res.raw.status_code == self.CREATED_CODE 81 | 82 | # test uploaded file can be found with list_folders 83 | file_nextcloud_href = os.path.join(WebDAV.API_URL, self.user_username, file_name) 84 | folder_info = self.nxc_local.list_folders(self.user_username, path=file_name) 85 | 86 | assert folder_info.is_ok 87 | assert len(folder_info.data) == 1 88 | assert isinstance(folder_info.data[0], dict) 89 | # check href 90 | assert folder_info.data[0]['href'] == file_nextcloud_href 91 | # test timestamp of uploaded file in Nextcloud 92 | assert folder_info.data[0]["last_modified"] == timestamp_str 93 | 94 | # remove file on local machine 95 | os.remove(file_local_path) 96 | self.nxc_local.download_file(self.user_username, file_name) 97 | 98 | # test file is downloaded to current dir 99 | assert file_name in os.listdir(".") 100 | with open(file_local_path, 'r') as f: 101 | downloaded_file_content = f.read() 102 | assert downloaded_file_content == file_content 103 | 104 | # test timestamp of downloaded file 105 | downloaded_file_timestamp = os.path.getmtime(file_local_path) 106 | assert downloaded_file_timestamp == timestamp 107 | 108 | # delete file 109 | self.nxc_local.delete_path(self.user_username, file_name) 110 | os.remove(file_local_path) 111 | 112 | def test_upload_download_file_using_local_file_property(self): 113 | file_name = "test_file_1000000000" 114 | file_content = "Test file: Sun, 09 Sep 2001 01:46:40 GMT" 115 | 116 | # create test file with timestamp: Sun, 09 Sep 2001 01:46:40 GMT 117 | timestamp_str = "Sun, 09 Sep 2001 01:46:40 GMT" 118 | timestamp = timestamp_to_epoch_time(timestamp_str) 119 | assert timestamp == 1000000000 120 | 121 | # create test file 122 | with open(file_name, "w") as f: 123 | f.write(file_content) 124 | file_local_path = os.path.abspath(file_name) 125 | 126 | # set timestamp to saved test file 127 | os.utime(file_local_path, (timestamp, timestamp)) 128 | 129 | # upload test file, timestamp comes from local file's property 130 | res = self.nxc_local.upload_file(self.user_username, file_local_path, file_name, timestamp=None) 131 | 132 | # check status code 133 | assert res.is_ok 134 | assert res.raw.status_code == self.CREATED_CODE 135 | 136 | # test uploaded file can be found with list_folders 137 | file_nextcloud_href = os.path.join(WebDAV.API_URL, self.user_username, file_name) 138 | folder_info = self.nxc_local.list_folders(self.user_username, path=file_name) 139 | 140 | assert folder_info.is_ok 141 | assert len(folder_info.data) == 1 142 | assert isinstance(folder_info.data[0], dict) 143 | # check href 144 | assert folder_info.data[0]['href'] == file_nextcloud_href 145 | # test timestamp of uploaded file in Nextcloud 146 | assert folder_info.data[0]["last_modified"] == timestamp_str 147 | 148 | # remove file on local machine 149 | os.remove(file_local_path) 150 | self.nxc_local.download_file(self.user_username, file_name) 151 | 152 | # test file is downloaded to current dir 153 | assert file_name in os.listdir(".") 154 | with open(file_local_path, 'r') as f: 155 | downloaded_file_content = f.read() 156 | assert downloaded_file_content == file_content 157 | 158 | # test timestamp of downloaded file 159 | downloaded_file_timestamp = os.path.getmtime(file_local_path) 160 | assert downloaded_file_timestamp == timestamp 161 | 162 | # delete file 163 | self.nxc_local.delete_path(self.user_username, file_name) 164 | os.remove(file_local_path) 165 | 166 | 167 | def test_create_folder(self): 168 | folder_name = "test folder5" 169 | res = self.nxc_local.create_folder(self.user_username, folder_name) 170 | assert res.is_ok 171 | assert res.raw.status_code == self.CREATED_CODE 172 | 173 | # test uploaded file can be found with list_folders 174 | file_nextcloud_href = quote(os.path.join(WebDAV.API_URL, self.user_username, folder_name)) + "/" 175 | folder_info = self.nxc_local.list_folders(self.user_username, path=folder_name) 176 | assert folder_info.is_ok 177 | assert len(folder_info.data) == 1 178 | assert isinstance(folder_info.data[0], dict) 179 | # check href 180 | assert folder_info.data[0]['href'] == file_nextcloud_href 181 | # check that created file type is a collection 182 | assert folder_info.data[0]['resource_type'] == self.COLLECTION_TYPE 183 | 184 | nested_folder_name = "test folder5/nested/folder" 185 | res = self.nxc_local.assure_tree_exists(self.user_username, nested_folder_name) 186 | folder_info = self.nxc_local.list_folders(self.user_username, path=nested_folder_name) 187 | assert folder_info.is_ok 188 | assert len(folder_info.data) == 1 189 | 190 | # check 405 status code if location already exists 191 | res = self.nxc_local.create_folder(self.user_username, folder_name) 192 | assert not res.is_ok 193 | assert res.raw.status_code == self.ALREADY_EXISTS_CODE 194 | 195 | # delete folder 196 | res = self.nxc_local.delete_path(self.user_username, folder_name) 197 | assert res.is_ok 198 | assert res.raw.status_code == self.NO_CONTENT_CODE 199 | 200 | def test_delete_path(self): 201 | # test delete empty folder 202 | new_path_name = "path_to_delete" 203 | self.nxc_local.create_folder(self.user_username, new_path_name) 204 | res = self.nxc_local.delete_path(self.user_username, new_path_name) 205 | assert res.raw.status_code == self.NO_CONTENT_CODE 206 | assert res.is_ok 207 | res = self.nxc_local.list_folders(self.user_username, new_path_name) 208 | assert res.data is None 209 | 210 | # test delete file 211 | # create file at first 212 | file_name = "test_file" 213 | file_content = "test file content" 214 | with open(file_name, "w") as f: 215 | f.write(file_content) 216 | file_local_path = os.path.abspath(file_name) 217 | self.nxc_local.upload_file(self.user_username, file_local_path, file_name) 218 | # delete file 219 | res = self.nxc_local.delete_path(self.user_username, file_name) 220 | assert res.raw.status_code == self.NO_CONTENT_CODE 221 | assert res.is_ok 222 | res = self.nxc_local.list_folders(self.user_username, new_path_name) 223 | assert res.data is None 224 | 225 | # test delete nonexistent file 226 | res = self.nxc_local.delete_path(self.user_username, file_name) 227 | assert res.raw.status_code == self.NOT_FOUND_CODE 228 | assert not res.is_ok 229 | 230 | def test_copy_path(self): 231 | # create a file to copy 232 | file_name = "test_file" 233 | file_content = "test file content" 234 | self.create_and_upload_file(file_name, file_content) 235 | 236 | # copy file 237 | destination_path = "new_test_file_location" 238 | res = self.nxc_local.copy_path(self.user_username, file_name, destination_path) 239 | assert res.raw.status_code == self.CREATED_CODE 240 | assert res.is_ok 241 | # check both file exist 242 | original_file_props = self.nxc_local.list_folders(self.user_username, file_name) 243 | copy_props = self.nxc_local.list_folders(self.user_username, destination_path) 244 | assert len(original_file_props.data) == 1 245 | assert len(copy_props.data) == 1 246 | 247 | # copy file to already exist location 248 | # create new file 249 | new_file_name = 'test_file_2' 250 | new_file_content = 'test_file_3' 251 | self.create_and_upload_file(new_file_name, new_file_content) 252 | res = self.nxc_local.copy_path(self.user_username, file_name, new_file_name) 253 | assert not res.is_ok 254 | assert res.raw.status_code == self.PRECONDITION_FAILED_CODE 255 | # copy with overriding 256 | res = self.nxc_local.copy_path(self.user_username, file_name, new_file_name, overwrite=True) 257 | assert res.is_ok 258 | assert res.raw.status_code == self.NO_CONTENT_CODE 259 | 260 | # download just copied file and check content 261 | os.remove(os.path.join(os.getcwd(), new_file_name)) # remove file locally to download it 262 | self.nxc_local.download_file(self.user_username, new_file_name) 263 | with open(new_file_name, 'r') as f: 264 | downloaded_file_content = f.read() 265 | assert downloaded_file_content == file_content 266 | assert downloaded_file_content != new_file_content 267 | 268 | def test_move_path(self): 269 | # create a file to move 270 | file_name = "test_move_file 🇳🇴 😗 🇫🇴 🇦🇽" 271 | file_content = "test move file content 🇳🇴 😗 🇫🇴 🇦🇽" 272 | self.create_and_upload_file(file_name, file_content) 273 | 274 | # move file 275 | destination_path = "new_test_move_file_location 🇳🇴 😗 🇫🇴 🇦🇽" 276 | res = self.nxc_local.move_path(self.user_username, file_name, destination_path) 277 | assert res.is_ok 278 | assert res.raw.status_code == self.CREATED_CODE 279 | # check only new file exist 280 | original_file_props = self.nxc_local.list_folders(self.user_username, file_name) 281 | moved_file = self.nxc_local.list_folders(self.user_username, destination_path) 282 | assert original_file_props.data is None 283 | assert len(moved_file.data) == 1 284 | 285 | # copy file to already exist location 286 | 287 | # create a file to move 288 | file_name = "test_move_file 🇳🇴 😗 🇫🇴 🇦🇽" 289 | file_content = "test move file content 🇳🇴 😗 🇫🇴 🇦🇽" 290 | self.create_and_upload_file(file_name, file_content) 291 | 292 | # create new file for conflict 293 | new_file_name = 'test_move_file_ 🇳🇴 😗 🇫🇴 🇦🇽' 294 | new_file_content = 'test_move_file_ 🇳🇴 😗 🇫🇴 🇦🇽' 295 | self.create_and_upload_file(new_file_name, new_file_content) 296 | 297 | # move file to the new file location 298 | res = self.nxc_local.move_path(self.user_username, file_name, new_file_name) 299 | assert not res.is_ok 300 | assert res.raw.status_code == self.PRECONDITION_FAILED_CODE 301 | # move with overriding 302 | res = self.nxc_local.move_path(self.user_username, file_name, new_file_name, overwrite=True) 303 | assert res.is_ok 304 | assert res.raw.status_code == self.NO_CONTENT_CODE 305 | 306 | # download just copied file and check content 307 | os.remove(os.path.join(os.getcwd(), new_file_name)) # remove file locally to download it 308 | self.nxc_local.download_file(self.user_username, new_file_name) 309 | with open(new_file_name, 'r') as f: 310 | downloaded_file_content = f.read() 311 | assert downloaded_file_content == file_content 312 | assert downloaded_file_content != new_file_content 313 | 314 | def test_set_list_favorites(self): 315 | # create new file to make favorite 316 | file_name = "test_favorite" 317 | file_content = "test favorite content" 318 | self.create_and_upload_file(file_name, file_content) 319 | file_nextcloud_href = os.path.join(WebDAV.API_URL, self.user_username, file_name) 320 | 321 | # get favorites 322 | res = self.nxc_local.list_favorites(self.user_username) 323 | assert len(res.data) == 0 324 | 325 | # set file as favorite 326 | res = self.nxc_local.set_favorites(self.user_username, file_name) 327 | assert res.is_ok 328 | assert res.raw.status_code == self.MULTISTATUS_CODE 329 | 330 | # check file is in favorites 331 | res = self.nxc_local.list_favorites(self.user_username) 332 | assert res.is_ok 333 | assert len(res.data) == 1 334 | assert res.data[0]['href'] == file_nextcloud_href 335 | 336 | def test_timestamp_to_epoch_time(self): 337 | timestamp_str = "Thu, 01 Dec 1994 16:00:00 GMT" 338 | timestamp_unix_time = timestamp_to_epoch_time(timestamp_str) 339 | assert timestamp_unix_time == 786297600 340 | assert timestamp_str == datetime.utcfromtimestamp(timestamp_unix_time).strftime('%a, %d %b %Y %H:%M:%S GMT') 341 | 342 | timestamp_str = "Fri, 14 Jul 2017 02:40:00 GMT" 343 | timestamp_unix_time = timestamp_to_epoch_time(timestamp_str) 344 | assert timestamp_unix_time == 1500000000 345 | assert timestamp_str == datetime.utcfromtimestamp(timestamp_unix_time).strftime('%a, %d %b %Y %H:%M:%S GMT') 346 | 347 | # UTM timezone is invalid for WebDav 348 | timestamp_str = "Thu, 01 Dec 1994 16:00:00 UTM" 349 | timestamp_unix_time = timestamp_to_epoch_time(timestamp_str) 350 | assert timestamp_unix_time is None 351 | assert timestamp_unix_time != 786297600 352 | 353 | # RFC 850 (part of http-date) format is invalid for WebDav 354 | timestamp_str = "Sunday, 06-Nov-94 08:49:37 GMT" 355 | timestamp_unix_time = timestamp_to_epoch_time(timestamp_str) 356 | assert timestamp_unix_time is None 357 | assert timestamp_unix_time != 784111777 358 | 359 | # ISO 8601 is invalid for WebDav 360 | timestamp_str = "2007-03-01T13:00:00Z" 361 | timestamp_unix_time = timestamp_to_epoch_time(timestamp_str) 362 | assert timestamp_unix_time is None 363 | assert timestamp_unix_time != 1172754000 364 | 365 | # broken date string 366 | timestamp_str = " " 367 | timestamp_unix_time = timestamp_to_epoch_time(timestamp_str) 368 | assert timestamp_unix_time is None 369 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | --------------------------------------------------------------------------------