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..8b3f261 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, JSONResponse
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, status: int = 200):
+ print(f"user_get: {request.headers}", flush=True)
+ return JSONResponse(content="Page for the registered users only!", status_code=status)
+
+
+@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