diff --git a/.nextcloudignore b/.nextcloudignore index 30582f59..26488d0d 100644 --- a/.nextcloudignore +++ b/.nextcloudignore @@ -20,7 +20,6 @@ /composer.* /node_modules /screenshots -/examples /docs /src /vendor/bin diff --git a/APPS.md b/APPS.md index fbd2aca7..7671eeb5 100644 --- a/APPS.md +++ b/APPS.md @@ -1,10 +1,11 @@ ## List of the Nextcloud ExApps, using AppAPI: -| Name | Language | Type | Description | Link | -|---------------------|----------|-------------|------------------------------|---------------------------------------------------------------| -| talk_bot_ai_example | Python | application | Talk Bot demonstration | [GitHub](https://github.com/cloud-py-api/talk_bot_ai_example) | -| upscaler_example | Python | application | Image UpScaler demonstration | [GitHub](https://github.com/cloud-py-api/upscaler_example) | -| nc_py_api | Python | library | Python library for Nextcloud | [GitHub](https://github.com/cloud-py-api/nc_py_api) | +| Name | Language | Type | Description | Link | +|---------------------|----------|--------------|------------------------------|---------------------------------------------------------------| +| talk_bot_ai_example | Python | application | Talk Bot demonstration | [GitHub](https://github.com/cloud-py-api/talk_bot_ai_example) | +| to_gif_example | Python | application | Simple FileAction API demo | [GitHub](https://github.com/cloud-py-api/to_gif_example) | +| upscaler_example | Python | application | Image UpScaler demonstration | [GitHub](https://github.com/cloud-py-api/upscaler_example) | +| nc_py_api | Python | library | Python library for Nextcloud | [GitHub](https://github.com/cloud-py-api/nc_py_api) | _If you wish to develop an application or library, we will gladly help and assist you._ diff --git a/examples/to_gif__python/Dockerfile b/examples/to_gif__python/Dockerfile deleted file mode 100644 index d45d39b5..00000000 --- a/examples/to_gif__python/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM python:3.11-bookworm - -COPY requirements.txt / -ADD /src/ /app/ - -RUN \ - apt-get update && \ - apt-get install -y \ - ffmpeg libsm6 libxext6 gifsicle - -RUN \ - python3 -m pip install -r requirements.txt && rm -rf ~/.cache && rm requirements.txt - -WORKDIR /app -ENTRYPOINT ["python3", "main.py"] diff --git a/examples/to_gif__python/Makefile b/examples/to_gif__python/Makefile deleted file mode 100644 index f74a8a02..00000000 --- a/examples/to_gif__python/Makefile +++ /dev/null @@ -1,39 +0,0 @@ -.DEFAULT_GOAL := help - -.PHONY: help -help: - @echo "Welcome to ToGif example. Please use \`make \` where is one of" - @echo " " - @echo " Next commands are only for dev environment with nextcloud-docker-dev!" - @echo " They should run from the host you are developing on(with activated venv) and not in the container with Nextcloud!" - @echo " " - @echo " build-push build image and upload to ghcr.io" - @echo " " - @echo " deploy deploy example to registered 'docker_dev'" - @echo " " - @echo " run27 install ToGif for Nextcloud 27" - @echo " " - @echo " manual_register27 perform registration of running 'to_gif' into the 'manual_install' deploy daemon." - -.PHONY: build-push -build-push: - docker login ghcr.io - docker buildx build --push --platform linux/arm64/v8,linux/amd64 --tag ghcr.io/cloud-py-api/to_gif__python:latest . - -.PHONY: deploy -deploy: - docker exec master-stable27-1 sudo -u www-data php occ app_api:app:deploy to_gif docker_dev \ - --info-xml https://raw.githubusercontent.com/cloud-py-api/app_api/main/examples/to_gif__python/appinfo/info.xml - -.PHONY: run27 -run27: - docker exec master-stable27-1 sudo -u www-data php occ app_api:app:unregister to_gif --silent || true - docker exec master-stable27-1 sudo -u www-data php occ app_api:app:register to_gif docker_dev -e --force-scopes \ - --info-xml https://raw.githubusercontent.com/cloud-py-api/app_api/main/examples/to_gif__python/appinfo/info.xml - -.PHONY: manual_register27 -manual_register27: - docker exec master-stable27-1 sudo -u www-data php occ app_api:app:unregister to_gif --silent || true - docker exec master-stable27-1 sudo -u www-data php occ app_api:app:register to_gif manual_install --json-info \ - "{\"appid\":\"to_gif\",\"name\":\"to_gif\",\"daemon_config_name\":\"manual_install\",\"version\":\"1.0.0\",\"secret\":\"12345\",\"host\":\"host.docker.internal\",\"port\":9031,\"scopes\":{\"required\":[\"FILES\", \"NOTIFICATIONS\"],\"optional\":[]},\"protocol\":\"http\",\"system_app\":0}" \ - -e --force-scopes diff --git a/examples/to_gif__python/appinfo/info.xml b/examples/to_gif__python/appinfo/info.xml deleted file mode 100644 index 1504991e..00000000 --- a/examples/to_gif__python/appinfo/info.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - to_gif - ToGif - Nextcloud To Gif Example - - - - 1.0.0 - MIT - Andrey Borysenko - Alexander Piskun - ToGifExample - tools - https://github.com/cloud-py-api/app_ecosystem_v2 - https://github.com/cloud-py-api/app_ecosystem_v2/issues - https://github.com/cloud-py-api/app_ecosystem_v2 - - - - - - ghcr.io - cloud-py-api/to_gif__python - latest - - - - FILES - NOTIFICATIONS - - - - - http - false - - diff --git a/examples/to_gif__python/requirements.txt b/examples/to_gif__python/requirements.txt deleted file mode 100644 index 4cab744a..00000000 --- a/examples/to_gif__python/requirements.txt +++ /dev/null @@ -1,10 +0,0 @@ -pygifsicle -imageio -opencv-python -numpy -uvicorn[standard]>=0.23.2 -fastapi>=0.101 -httpx>=0.24.1 -pydantic>=2.1.1 -requests>=2.31 -xmltodict>=0.13 diff --git a/examples/to_gif__python/src/main.py b/examples/to_gif__python/src/main.py deleted file mode 100644 index 57077e81..00000000 --- a/examples/to_gif__python/src/main.py +++ /dev/null @@ -1,268 +0,0 @@ -"""Simplest example of files_dropdown_menu, notification without using the framework.""" - -import json -import os -import tempfile -import typing -from base64 import b64encode, b64decode -from random import choice -from string import ascii_lowercase, ascii_uppercase, digits -from urllib.parse import quote - -import cv2 -import httpx -import imageio -import numpy -import uvicorn -from fastapi import BackgroundTasks, FastAPI, HTTPException, Request, responses, status -from pydantic import BaseModel -from pygifsicle import optimize -from requests import Response - -APP = FastAPI() - - -class UiActionFileInfo(BaseModel): - fileId: int - name: str - directory: str - etag: str - mime: str - fileType: str - size: int - favorite: str - permissions: int - mtime: int - userId: str - shareOwner: typing.Optional[str] - shareOwnerId: typing.Optional[str] - instanceId: typing.Optional[str] - - -class UiFileActionHandlerInfo(BaseModel): - actionName: str - actionHandler: str - actionFile: UiActionFileInfo - - -def random_string(size: int) -> str: - return "".join(choice(ascii_lowercase + ascii_uppercase + digits) for _ in range(size)) - - -def sign_request(headers: dict, user="") -> None: - headers["AUTHORIZATION-APP-API"] = b64encode(f"{user}:{os.environ['APP_SECRET']}".encode("UTF=8")) - headers["EX-APP-ID"] = os.environ["APP_ID"] - headers["EX-APP-VERSION"] = os.environ["APP_VERSION"] - headers["OCS-APIRequest"] = "true" - - -def sign_check(request: Request) -> str: - headers = { - "AA-VERSION": request.headers["AA-VERSION"], - "EX-APP-ID": request.headers["EX-APP-ID"], - "EX-APP-VERSION": request.headers["EX-APP-VERSION"], - "AUTHORIZATION-APP-API": request.headers.get("AUTHORIZATION-APP-API", ""), - } - # AA-VERSION contains AppAPI version, for now it can be only one version, so no handling of it. - if headers["EX-APP-ID"] != os.environ["APP_ID"]: - raise ValueError(f"Invalid EX-APP-ID:{headers['EX-APP-ID']} != {os.environ['APP_ID']}") - - if headers["EX-APP-VERSION"] != os.environ["APP_VERSION"]: - raise ValueError(f"Invalid EX-APP-VERSION:{headers['EX-APP-VERSION']} <=> {os.environ['APP_VERSION']}") - - auth_aa = b64decode(headers.get("AUTHORIZATION-APP-API", "")).decode("UTF-8") - username, app_secret = auth_aa.split(":", maxsplit=1) - if app_secret != os.environ["APP_SECRET"]: - raise ValueError(f"Invalid APP_SECRET:{app_secret} != {os.environ['APP_SECRET']}") - return username - - -def ocs_call( - method: str, - path: str, - params: typing.Optional[dict] = None, - json_data: typing.Optional[typing.Union[dict, list]] = None, - **kwargs, -): - method = method.upper() - if params is None: - params = {} - params.update({"format": "json"}) - headers = kwargs.pop("headers", {}) - data_bytes = None - if json_data is not None: - headers.update({"Content-Type": "application/json"}) - data_bytes = json.dumps(json_data).encode("utf-8") - sign_request(headers, kwargs.get("user", "")) - return httpx.request( - method, - url=os.environ["NEXTCLOUD_URL"] + path, - params=params, - content=data_bytes, - headers=headers, - ) - - -def dav_call(method: str, path: str, data: typing.Optional[typing.Union[str, bytes]] = None, **kwargs): - headers = kwargs.pop("headers", {}) - data_bytes = None - if data is not None: - data_bytes = data.encode("UTF-8") if isinstance(data, str) else data - path = quote("/remote.php/dav" + path) - sign_request(headers, kwargs.get("user", "")) - return httpx.request( - method, - url=os.environ["NEXTCLOUD_URL"] + path, - content=data_bytes, - headers=headers, - ) - - -def nc_log(log_lvl: int, content: str): - ocs_call("POST", "/ocs/v1.php/apps/app_api/api/v1/log", json_data={"level": log_lvl, "message": content}) - - -def create_notification(user_id: str, subject: str, message: str): - params: dict = { - "params": { - "object": "app_api", - "object_id": random_string(56), - "subject_type": "app_api_ex_app", - "subject_params": { - "rich_subject": subject, - "rich_subject_params": {}, - "rich_message": message, - "rich_message_params": {}, - }, - } - } - ocs_call( - method="POST", path=f"/ocs/v1.php/apps/app_api/api/v1/notification", json_data=params, user=user_id - ) - - -def convert_video_to_gif(input_file: UiActionFileInfo, user_id: str): - # save_path = path.splitext(input_file.user_path)[0] + ".gif" - if input_file.directory == "/": - dav_get_file_path = f"/files/{user_id}/{input_file.name}" - else: - dav_get_file_path = f"/files/{user_id}{input_file.directory}/{input_file.name}" - dav_save_file_path = os.path.splitext(dav_get_file_path)[0] + ".gif" - # =========================================================== - nc_log(2, f"Processing:{input_file.name}") - try: - with tempfile.NamedTemporaryFile(mode="w+b") as tmp_in: - # nc.files.download2stream(input_file, tmp_in) - # to simplify an example, we download to memory as one piece, instead of implementing stream - r = dav_call("GET", dav_get_file_path, user=user_id) - tmp_in.write(r.content) - # ============================================ - nc_log(2, "File downloaded") - tmp_in.flush() - cap = cv2.VideoCapture(tmp_in.name) - with tempfile.NamedTemporaryFile(mode="w+b", suffix=".gif") as tmp_out: - image_lst = [] - previous_frame = None - skip = 0 - while True: - skip += 1 - ret, frame = cap.read() - if frame is None: - break - if skip == 2: - skip = 0 - continue - if previous_frame is not None: - diff = numpy.mean(previous_frame != frame) - if diff < 0.91: - continue - frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - image_lst.append(frame_rgb) - previous_frame = frame - if len(image_lst) > 60: - break - cap.release() - imageio.mimsave(tmp_out.name, image_lst) - optimize(tmp_out.name) - nc_log(2, "GIF is ready") - # nc.files.upload_stream(save_path, tmp_out) - # to simplify an example, we upload as one piece, instead of implementing chunked stream upload - tmp_out.seek(0) - dav_call("PUT", dav_save_file_path, data=tmp_out.read(), user=user_id) - # ========================================== - nc_log(2, "Result uploaded") - create_notification( - user_id, - f"{input_file.name} finished!", - f"{os.path.splitext(input_file.name)[0] + '.gif'} is waiting for you!", - ) - except Exception as e: - nc_log(3, "ExApp exception:" + str(e)) - create_notification(user_id, "Error occurred", "Error information was written to log file") - - -@APP.post("/video_to_gif") -def video_to_gif( - file: UiFileActionHandlerInfo, - request: Request, - background_tasks: BackgroundTasks, -): - try: - user_id = sign_check(request) - except ValueError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) - background_tasks.add_task(convert_video_to_gif, file.actionFile, user_id) - return Response() - - -@APP.put("/enabled") -def enabled_callback( - enabled: bool, - request: Request, -): - try: - sign_check(request) - except ValueError: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) - r = "" - try: - print(f"enabled={enabled}") - if enabled: - # nc.ui.files_dropdown_menu.register("to_gif", "TO GIF", "/video_to_gif", mime="video") - ocs_call( - "POST", - "/ocs/v1.php/apps/app_api/api/v1/files/actions/menu", - json_data={ - "fileActionMenuParams": { - "name": "to_gif", - "display_name": "TO GIF", - "mime": "video", - "permissions": 31, - "order": 0, - "icon": "", - "icon_class": "icon-app-ecosystem-v2", - "action_handler": "/video_to_gif", - } - }, - ) - else: - # nc.ui.files_dropdown_menu.unregister("to_gif") - ocs_call( - "DELETE", - "/ocs/v1.php/apps/app_api/api/v1/files/actions/menu", - json_data={"fileActionMenuName": "to_gif"}, - ) - except Exception as e: - r = str(e) - return responses.JSONResponse(content={"error": r}, status_code=200) - - -@APP.get("/heartbeat") -def heartbeat_callback(): - return responses.JSONResponse(content={"status": "ok"}, status_code=200) - - -if __name__ == "__main__": - uvicorn.run( - "main:APP", host=os.environ.get("APP_HOST", "127.0.0.1"), port=int(os.environ["APP_PORT"]), log_level="trace" - ) diff --git a/tests/app_version_higher.py b/tests/app_version_higher.py index cef43568..bf28f6b7 100644 --- a/tests/app_version_higher.py +++ b/tests/app_version_higher.py @@ -9,11 +9,11 @@ nc_client.users.create("second_admin", password="2Very3Strong4", groups=["admin"]) nc_application = NextcloudApp(user="admin") - assert nc_application.users.get_details() # OCS call works + assert nc_application.users.get_user() # OCS call works assert not nc_application.notifications.get_all() # there are no notifications nc_application._session.adapter.headers.update({"EX-APP-VERSION": "99.0.0"}) # change ExApp version with pytest.raises(NextcloudException) as exc_info: - nc_application.users.get_details() # this call should be rejected by AppAPI + nc_application.users.get_user() # this call should be rejected by AppAPI assert exc_info.value.status_code == 401 assert nc_client.apps.ex_app_is_disabled("nc_py_api") is True