From d092dce73fdc72b7e4e097b1b3fc182a5f02adf7 Mon Sep 17 00:00:00 2001 From: Oleksander Piskun Date: Wed, 12 Feb 2025 19:25:14 +0200 Subject: [PATCH 1/2] Nextcloud 32: HaRP support Signed-off-by: Oleksander Piskun --- .gitattributes | 2 + Dockerfile | 23 +++++++++- appinfo/info.xml | 31 ++++++++++++- ex_app/lib/main.py | 112 +++++++++++++++++++++++++++++++++++++++++++-- healthcheck.sh | 8 +++- start.sh | 56 +++++++++++++++++++++++ test.sh | 20 ++++++++ 7 files changed, 245 insertions(+), 7 deletions(-) create mode 100644 .gitattributes create mode 100644 start.sh create mode 100755 test.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8560b79 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Declare files that always have LF line endings on checkout +* text eol=lf diff --git a/Dockerfile b/Dockerfile index 42e6b76..a7367c1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,9 @@ FROM python:3.11-slim-bookworm +RUN apt-get update && apt-get install -y curl procps && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + COPY requirements.txt / RUN \ @@ -12,7 +16,24 @@ ADD /ex_app/l10[n] /ex_app/l10n ADD /ex_app/li[b] /ex_app/lib COPY --chmod=775 healthcheck.sh / +COPY --chmod=775 start.sh / + +# Download and install FRP client +RUN set -ex; \ + ARCH=$(uname -m); \ + if [ "$ARCH" = "aarch64" ]; then \ + FRP_URL="https://raw.githubusercontent.com/cloud-py-api/HaRP/main/exapps_dev/frp_0.61.1_linux_arm64.tar.gz"; \ + else \ + FRP_URL="https://raw.githubusercontent.com/cloud-py-api/HaRP/main/exapps_dev/frp_0.61.1_linux_amd64.tar.gz"; \ + fi; \ + echo "Downloading FRP client from $FRP_URL"; \ + curl -L "$FRP_URL" -o /tmp/frp.tar.gz; \ + tar -C /tmp -xzf /tmp/frp.tar.gz; \ + mv /tmp/frp_0.61.1_linux_* /tmp/frp; \ + cp /tmp/frp/frpc /usr/local/bin/frpc; \ + chmod +x /usr/local/bin/frpc; \ + rm -rf /tmp/frp /tmp/frp.tar.gz WORKDIR /ex_app/lib -ENTRYPOINT ["python3", "main.py"] +ENTRYPOINT ["/start.sh"] HEALTHCHECK --interval=2s --timeout=2s --retries=300 CMD /healthcheck.sh diff --git a/appinfo/info.xml b/appinfo/info.xml index e22843a..8d4e497 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -6,7 +6,7 @@ - 2.0.0 + 3.0.0 MIT Alexander Piskun PyAppV2_skeleton @@ -15,7 +15,7 @@ https://github.com/nextcloud/app-skeleton-python/issues https://github.com/nextcloud/app-skeleton-python - + @@ -36,5 +36,32 @@ Test environment without default value + + + ^/public$ + GET + PUBLIC + + + ^/user$ + GET + USER + + + ^/admin$ + GET + ADMIN + + + ^/$ + GET + USER + + + ^/ws$ + GET + USER + + diff --git a/ex_app/lib/main.py b/ex_app/lib/main.py index 586a3bc..9ba72be 100644 --- a/ex_app/lib/main.py +++ b/ex_app/lib/main.py @@ -1,12 +1,16 @@ """Simplest example.""" +import asyncio +import datetime import os from contextlib import asynccontextmanager from pathlib import Path +from typing import Annotated -from fastapi import FastAPI +from fastapi import Depends, FastAPI, Request, WebSocket +from fastapi.responses import HTMLResponse from nc_py_api import NextcloudApp -from nc_py_api.ex_app import AppAPIAuthMiddleware, LogLvl, run_app, set_handlers +from nc_py_api.ex_app import AppAPIAuthMiddleware, LogLvl, nc_app, run_app, set_handlers @asynccontextmanager @@ -18,11 +22,113 @@ async def lifespan(app: FastAPI): APP = FastAPI(lifespan=lifespan) APP.add_middleware(AppAPIAuthMiddleware) # set global AppAPI authentication middleware +# Build the WebSocket URL dynamically using the NextcloudApp configuration. +WS_URL = NextcloudApp().app_cfg.endpoint + "/exapps/app-skeleton-python/ws" + +# HTML content served at the root URL. +# This page opens a WebSocket connection, displays incoming messages, +# and allows you to send messages back to the server. +HTML = f""" + + + + FastAPI WebSocket Demo + + +

FastAPI WebSocket Demo

+

Type a message and click "Send", or simply watch the server send cool updates!

+ + + + + + +""" + + +@APP.get("/") +async def get(): + # WebSockets works only in Nextcloud 32 when `HaRP` is used instead of `DSP` + return HTMLResponse(HTML) + + +@APP.get("/public") +async def public_get(request: Request): + print(f"public_get: {request.headers}", flush=True) + return "Public page!" + + +@APP.get("/user") +async def user_get(request: Request): + print(f"user_get: {request.headers}", flush=True) + return "Page for the registered users only!" + + +@APP.get("/admin") +async def admin_get(request: Request): + print(f"admin_get: {request.headers}", flush=True) + return "Admin page!" + + +@APP.websocket("/ws") +async def websocket_endpoint( + websocket: WebSocket, + nc: Annotated[NextcloudApp, Depends(nc_app)], +): + # WebSockets works only in Nextcloud 32 when `HaRP` is used instead of `DSP` + print(nc.user) # if you need user_id that initiated WebSocket connection + print(f"websocket_endpoint: {websocket.headers}", flush=True) + await websocket.accept() + + # This background task sends a periodic message (the current time) every 2 seconds. + async def send_periodic_messages(): + while True: + try: + message = f"Server time: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" + await websocket.send_text(message) + await asyncio.sleep(2) + except Exception as exc: + NextcloudApp().log(LogLvl.ERROR, str(exc)) + break + + # Start the periodic sender in the background. + periodic_task = asyncio.create_task(send_periodic_messages()) + + try: + # Continuously listen for messages from the client. + while True: + data = await websocket.receive_text() + # Echo the received message back to the client. + await websocket.send_text(f"Echo: {data}") + except Exception as e: + NextcloudApp().log(LogLvl.ERROR, str(e)) + finally: + # Cancel the periodic message task when the connection is closed. + periodic_task.cancel() + def enabled_handler(enabled: bool, nc: NextcloudApp) -> str: # This will be called each time application is `enabled` or `disabled` # NOTE: `user` is unavailable on this step, so all NC API calls that require it will fail as unauthorized. - print(f"enabled={enabled}") + print(f"enabled={enabled}", flush=True) if enabled: nc.log(LogLvl.WARNING, f"Hello from {nc.app_cfg.app_name} :)") else: diff --git a/healthcheck.sh b/healthcheck.sh index 8c3cbfc..d6319ea 100644 --- a/healthcheck.sh +++ b/healthcheck.sh @@ -1,3 +1,9 @@ #!/bin/bash -exit 0 +if [ -f /frpc.toml ] && [ -n "$HP_SHARED_KEY" ]; then + if pgrep -x "frpc" > /dev/null; then + exit 0 + else + exit 1 + fi +fi diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..66b6ed3 --- /dev/null +++ b/start.sh @@ -0,0 +1,56 @@ +#!/bin/bash +set -e + +# Check if the configuration file already exists +if [ -f /frpc.toml ]; then + echo "/frpc.toml already exists, skipping creation." +else + # Only create a config file if HP_SHARED_KEY is set. + if [ -n "$HP_SHARED_KEY" ]; then + echo "HP_SHARED_KEY is set, creating /frpc.toml configuration file..." + if [ -d "/certs/frp" ]; then + echo "Found /certs/frp directory. Creating configuration with TLS certificates." + cat < /frpc.toml +serverAddr = "$HP_FRP_ADDRESS" +serverPort = $HP_FRP_PORT +metadatas.token = "$HP_SHARED_KEY" +transport.tls.certFile = "/certs/frp/client.crt" +transport.tls.keyFile = "/certs/frp/client.key" +transport.tls.trustedCaFile = "/certs/frp/ca.crt" + +[[proxies]] +name = "$APP_ID" +type = "tcp" +localIP = "127.0.0.1" +localPort = $APP_PORT +remotePort = $APP_PORT +EOF + else + echo "Directory /certs/frp not found. Creating configuration without TLS certificates." + cat < /frpc.toml +serverAddr = "$HP_FRP_ADDRESS" +serverPort = $HP_FRP_PORT +metadatas.token = "$HP_SHARED_KEY" + +[[proxies]] +name = "$APP_ID" +type = "tcp" +localIP = "127.0.0.1" +localPort = $APP_PORT +remotePort = $APP_PORT +EOF + fi + else + echo "HP_SHARED_KEY is not set. Skipping FRP configuration." + fi +fi + +# If we have a configuration file and the shared key is present, start the FRP client +if [ -f /frpc.toml ] && [ -n "$HP_SHARED_KEY" ]; then + echo "Starting frpc in the background..." + frpc -c /frpc.toml & +fi + +# Start the main Python application +echo "Starting main application..." +exec python3 main.py diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..584d7d8 --- /dev/null +++ b/test.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e + +docker container remove --force nc_app_app-skeleton-python || true + +docker build -t nc_app_app-skeleton-python . + +docker run --rm \ + -e HP_SHARED_KEY="mysecret" \ + -e HP_FRP_ADDRESS="nextcloud-appapi-harp" \ + -e HP_FRP_PORT="8782" \ + -e APP_HOST="127.0.0.1" \ + -e APP_PORT="23090" \ + -e APP_ID="app-skeleton-python" \ + -e APP_SECRET="12345" \ + -e APP_VERSION="1.0.0" \ + -e NEXTCLOUD_URL="http://nextcloud.local" \ + --name nc_app_app-skeleton-python \ + --network=master_default \ + nc_app_app-skeleton-python From 323d6bb586668b629c653d6cad4d1220075c6001 Mon Sep 17 00:00:00 2001 From: Oleksander Piskun Date: Fri, 14 Feb 2025 08:47:05 +0200 Subject: [PATCH 2/2] added some stuff for the testing process Signed-off-by: Oleksander Piskun --- ex_app/lib/main.py | 6 +++--- test.sh | 20 -------------------- 2 files changed, 3 insertions(+), 23 deletions(-) delete mode 100755 test.sh diff --git a/ex_app/lib/main.py b/ex_app/lib/main.py index 9ba72be..8b3f261 100644 --- a/ex_app/lib/main.py +++ b/ex_app/lib/main.py @@ -8,7 +8,7 @@ from typing import Annotated from fastapi import Depends, FastAPI, Request, WebSocket -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, JSONResponse from nc_py_api import NextcloudApp from nc_py_api.ex_app import AppAPIAuthMiddleware, LogLvl, nc_app, run_app, set_handlers @@ -77,9 +77,9 @@ async def public_get(request: Request): @APP.get("/user") -async def user_get(request: Request): +async def user_get(request: Request, status: int = 200): print(f"user_get: {request.headers}", flush=True) - return "Page for the registered users only!" + return JSONResponse(content="Page for the registered users only!", status_code=status) @APP.get("/admin") diff --git a/test.sh b/test.sh deleted file mode 100755 index 584d7d8..0000000 --- a/test.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash -set -e - -docker container remove --force nc_app_app-skeleton-python || true - -docker build -t nc_app_app-skeleton-python . - -docker run --rm \ - -e HP_SHARED_KEY="mysecret" \ - -e HP_FRP_ADDRESS="nextcloud-appapi-harp" \ - -e HP_FRP_PORT="8782" \ - -e APP_HOST="127.0.0.1" \ - -e APP_PORT="23090" \ - -e APP_ID="app-skeleton-python" \ - -e APP_SECRET="12345" \ - -e APP_VERSION="1.0.0" \ - -e NEXTCLOUD_URL="http://nextcloud.local" \ - --name nc_app_app-skeleton-python \ - --network=master_default \ - nc_app_app-skeleton-python