From 7ad90812ea6d240ada4cfd4aac75862dc6753260 Mon Sep 17 00:00:00 2001 From: Sagar Date: Tue, 17 Sep 2024 14:12:00 +0545 Subject: [PATCH 1/5] Added manaul installation of external app as openproject Signed-off-by: Sagar --- README.md | 108 ++++++++++++++++++++- ex_app_run_script.sh.example | 15 +++ lib/main.py | 181 +++++++++++++++++++++++++++++++---- 3 files changed, 287 insertions(+), 17 deletions(-) create mode 100644 ex_app_run_script.sh.example diff --git a/README.md b/README.md index 0f12c0c..71a8021 100644 --- a/README.md +++ b/README.md @@ -1 +1,107 @@ -# Nextcloud-OpenProject-App repository \ No newline at end of file +# OpenProject as Nextcloud's External App + +## Manual Installation +For the manual installation of `OpenProject` as an external application of `Nextcloud`, make sure that your `Nextcloud` as well as `OpenProject` instance is up and running. + +### 1. Install `app_api` application + +Assuming you’re in the apps folder of Nextcloud with command git: + +- Clone + ```bash + https://github.com/cloud-py-api/app_api.git + ``` +- build + ```bash + cd app_api + npm ci && npm run dev + ``` +- Enable the `app_api` + + ```bash + # Assuming you’re in nextcloud server root directory + sudo -u www-data php occ a:e app_api + ``` + +### 2. Register deploy daemons (In Nextcloud) + +- Navigate to `Administration Settings > AppAPI` +- Click register daemons +- Select `Manual Install` for Daemon configuration template +- Put `manual_install` for name and display name +- Deployment method as `manual-install` +- Daemon host as `localhost` +- Click Register + +### 3. Running OpenProject locally +Set up and build `OpenProject` locally following [OpenProject Development Setup](https://www.openproject.org/docs/development/development-environment/) +After the setup, run `OpenProject` locally with the given command line. + +>NOTE: while running the below bash command line for `NC_SUB_FOLDER` put the sub folder path if you are running nextcloud in a sub folder else do remove it. + +```bash +OPENPROJECT_AUTHENTICATION_GLOBAL__BASIC__AUTH_USER=apiadmin \ +OPENPROJECT_AUTHENTICATION_GLOBAL__BASIC__AUTH_PASSWORD=apiadmin \ +OPENPROJECT_RAILS__RELATIVE__URL__ROOT=//index.php/apps/app_api/proxy/openproject-nextcloud-app \ +OPENPROJECT_2FA_DISABLED=false \ +foreman start -f Procfile.dev +``` + +### 4. Configure and Run External `openproject-nextcloud-app` application +Assuming you’re in the apps folder of Nextcloud with command git: + +- Clone + ```bash + https://github.com/JankariTech/openproject-nextcloud-app.git + ``` +- Configure script before running external app + ```bash + cd openproject-nextcloud-app + cp ex_app_run_script.sh.example ex_app_run_script.sh + ``` + Once you have copied the script to run the external application, configure the following environments + + - APP_ID is the application id of the external app + - APP_PORT is port for the external app + - APP_HOST is the host for the external app + - APP_SECRET is the secret required for the communication between external app and nextcloud + - APP_VERSION is the version of external app + - AA_VERSION is the app_api version used + - EX_APP_VERSION is the version of external app + - EX_APP_ID is the application id of the external app + - NC_SUB_FOLDER is the subfolder in which nextcloud is running (make sure to use same in OPENPROJECT_RAILS__RELATIVE__URL__ROOT while running openproject) + - OP_BACKEND_URL is the url in which `OpenProject` is up and running + - NEXTCLOUD_URL the url in which `Nextcloud` is up and running + +- Run external application with the script + ```bash + bash ex_app_run_script.sh + ``` + +### 2. Register and deploy external application `openproject-nextcloud-app` in Nextcloud's external apps + +Assuming you’re in nextcloud server root directory + +- Register and deploy external application `openproject-nextcloud-app` + ```bash + sudo -u www-data php occ app_api:app:register openproject-nextcloud-app manual_install --json-info \ + "{\"id\":\"\", + \"name\":\"\", + \"daemon_config_name\":\"manual_install\", + \"version\":\"\", + \"secret\":\"\", + \"scopes\":[\"ALL\"], + \"port\":, + \"routes\": [{\"url\":\".*\",\"verb\":\"GET, POST, PUT, DELETE, HEAD, PATCH, OPTIONS, TRACE\", + \"access_level\":1, + \"headers_to_exclude\":[]}]}" \ + --force-scopes --wait-finish + ``` + In the above bash command use the same value for `EX_APP_ID`, `EX_APP_VERSION`, `APP_SECRET`, and `APP_PORT` used while running external applicaiton `openproject-nextcloud-app` + + +Upon successful running , register and deploy of the external application `openproject-nextcloud-app`, the external application can be accessed with the url: +```bash +http://${APP_HOST}/${NC_SUB_FOLDER}/index.php/apps/app_api/proxy/openproject-nextcloud-app +``` + diff --git a/ex_app_run_script.sh.example b/ex_app_run_script.sh.example new file mode 100644 index 0000000..9a868dd --- /dev/null +++ b/ex_app_run_script.sh.example @@ -0,0 +1,15 @@ +#!/bin/bash + +export APP_ID="openproject-nextcloud-app" +export APP_PORT="9030" +export APP_HOST="localhost" +export APP_SECRET="" +export APP_VERSION="" +export AA_VERSION="" +export EX_APP_VERSION="" +export EX_APP_ID="openproject-nextcloud-app" +export NC_SUB_FOLDER="" +export OP_BACKEND_URL="http://:/${NC_SUB_FOLDER}/index.php/apps/app_api/proxy/openproject-nextcloud-app" +export NEXTCLOUD_URL="http://${APP_HOST}/${NC_SUB_FOLDER}/index.php" + +python3.10 lib/main.py \ No newline at end of file diff --git a/lib/main.py b/lib/main.py index aed4645..9fca98e 100644 --- a/lib/main.py +++ b/lib/main.py @@ -1,35 +1,184 @@ -"""Simplest example.""" - +import typing +import httpx +import os +from urllib.parse import urlparse, parse_qs +import urllib.parse +from urllib.parse import urlencode +import json +from starlette.responses import Response, JSONResponse from contextlib import asynccontextmanager - -from fastapi import FastAPI +from fastapi import FastAPI, Request, BackgroundTasks, Depends +from fastapi.middleware.cors import CORSMiddleware 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, run_app, nc_app +from nc_py_api.ex_app.integration_fastapi import fetch_models_task @asynccontextmanager async def lifespan(app: FastAPI): - set_handlers(app, enabled_handler) yield APP = FastAPI(lifespan=lifespan) -APP.add_middleware(AppAPIAuthMiddleware) # set global AppAPI authentication middleware +APP.add_middleware(AppAPIAuthMiddleware) +APP.add_middleware( + CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"] +) 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"{nc.app_cfg.app_name}={enabled}") if enabled: - nc.log(LogLvl.WARNING, f"Hello from {nc.app_cfg.app_name} :)") + nc.log(LogLvl.INFO, f"{nc.app_cfg.app_name} is enabled") else: - nc.log(LogLvl.WARNING, f"Bye bye from {nc.app_cfg.app_name} :(") - # In case of an error, a non-empty short string should be returned, which will be shown to the NC administrator. + nc.log(LogLvl.INFO, f"{nc.app_cfg.app_name} is disabled") return "" +@APP.get("/heartbeat") +async def heartbeat_callback(): + return JSONResponse(content={"status": "ok"}) + + +@APP.post("/init") +async def init_callback( + b_tasks: BackgroundTasks, nc: typing.Annotated[NextcloudApp, Depends(nc_app)] +): + b_tasks.add_task(fetch_models_task, nc, {}, 0) + return JSONResponse(content={}) + + +@APP.put("/enabled") +async def enabled_callback( + enabled: bool, nc: typing.Annotated[NextcloudApp, Depends(nc_app)] +): + return JSONResponse(content={"error": enabled_handler(enabled, nc)}) + + +@APP.api_route( + "/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "HEAD", "PATCH", "OPTIONS"] +) +async def proxy_Requests(_request: Request, path: str): + response = await proxy_request_to_server(_request, path) + + headers = dict(response.headers) + headers.pop("transfer-encoding", None) + headers.pop("content-encoding", None) + headers["content-length"] = str(response.content.__len__()) + headers["content-security-policy"] = ( + "default-src * 'unsafe-inline' 'unsafe-eval' data: blob:;" + ) + + return Response( + content=response.content, + status_code=response.status_code, + headers=headers, + ) + + +async def proxy_request_to_server(request: Request, path: str): + async with httpx.AsyncClient(follow_redirects=False) as client: + backend_url = get_backend_url() + url = f"{backend_url}/{path}" + headers = {} + for k, v in request.headers.items(): + # NOTE: + # - remove 'host' to make op routes work + # - remove 'origin' to validate csrf + if k == "host" or k == "origin": + continue + headers[k] = v + + if request.method == "GET": + params=request.query_params + # A referrer header is required when we request to '/work_packages/menu' enpoint + # Currently the browser does not provide the referer header so it has been put through proxy + # Also it works even referrer is empty + if url.endswith("/work_packages/menu"): + headers.update({'referer': ''}) + + if "/project_storages/new" in url : + # when requesting the storate_id is stripped in the proxy (issue: https://github.com/cloud-py-api/app_api/issues/384). + # This piece of code modifies the query param to add missing storage_id. + query_params = dict(params) + if 'storages_project_storage[]' in query_params: + value = query_params['storages_project_storage[]'] + new_key = 'storages_project_storage[storage_id]' + query_params[new_key] = value + del query_params['storages_project_storage[]'] + params = urlencode(query_params, doseq=True) + response = await client.get( + url, + params=params, + headers=headers, + ) + else: + # for getting oauth token the content-type 'application/x-www-form-urlencoded' sems to be required + if request.method == 'POST' and "/oauth/token" in url: + headers.update({'content-type': 'application/x-www-form-urlencoded'}) + response = await client.request( + method=request.method, + url=url, + params=request.query_params, + headers=headers, + content=await request.body(), + ) + + + if response.is_redirect and not response.status_code == 304: + if "location" in response.headers and "proxy/openproject-nextcloud-app" in response.headers["location"]: + redirect_path = urlparse(response.headers["location"]).path + redirect_url = get_nc_url() + redirect_path + response.headers["location"] = redirect_url + response.status_code = 200 + elif "oauth/authorize" in url: + response.headers["location"] = response.headers["location"] + elif "apps/oauth2/authorize" in response.headers["location"]: + response.headers["location"] = response.headers["location"] + response.status_code = 200 + else: + headers["content-length"] = "0" + response = await handle_redirects( + client, + request.method if response.status_code == 307 else "GET", + response.headers["location"], + headers, + ) + return response + + +async def handle_redirects( + client: httpx.AsyncClient, + method: str, + url: str, + headers: dict, +): + response = await client.request( + method=method, + url=url, + headers=headers, + ) + + if response.is_redirect: + return await handle_redirects( + client, + method if response.status_code == 307 else "GET", + response.headers["location"], + headers, + ) + + return response + + +def get_backend_url(): + return os.getenv("OP_BACKEND_URL", "http://localhost:3000") + + +def get_nc_url(): + nc_url = os.getenv("NEXTCLOUD_URL", "http://localhost/index.php") + url = urlparse(nc_url) + return f"{url.scheme}://{url.netloc}" + + if __name__ == "__main__": - # Wrapper around `uvicorn.run`. - # You are free to call it directly, with just using the `APP_HOST` and `APP_PORT` variables from the environment. - run_app("main:APP", log_level="trace") + run_app("main:APP", log_level="trace") \ No newline at end of file From 00f3d76c7365916d0185688290cca4a20c731bd0 Mon Sep 17 00:00:00 2001 From: Sagar Date: Tue, 17 Sep 2024 14:55:07 +0545 Subject: [PATCH 2/5] Update readme Signed-off-by: Sagar --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 71a8021..9e0bf1a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Assuming you’re in the apps folder of Nextcloud with command git: - Clone ```bash - https://github.com/cloud-py-api/app_api.git + git clone https://github.com/cloud-py-api/app_api.git ``` - build ```bash @@ -52,7 +52,7 @@ Assuming you’re in the apps folder of Nextcloud with command git: - Clone ```bash - https://github.com/JankariTech/openproject-nextcloud-app.git + git clone https://github.com/JankariTech/openproject-nextcloud-app.git ``` - Configure script before running external app ```bash @@ -78,7 +78,7 @@ Assuming you’re in the apps folder of Nextcloud with command git: bash ex_app_run_script.sh ``` -### 2. Register and deploy external application `openproject-nextcloud-app` in Nextcloud's external apps +### 5. Register and deploy external application `openproject-nextcloud-app` in Nextcloud's external apps Assuming you’re in nextcloud server root directory @@ -97,7 +97,7 @@ Assuming you’re in nextcloud server root directory \"headers_to_exclude\":[]}]}" \ --force-scopes --wait-finish ``` - In the above bash command use the same value for `EX_APP_ID`, `EX_APP_VERSION`, `APP_SECRET`, and `APP_PORT` used while running external applicaiton `openproject-nextcloud-app` + In the above bash command use the same value for `EX_APP_ID`, `EX_APP_VERSION`, `APP_SECRET`, and `APP_PORT` used while running external app `openproject-nextcloud-app` Upon successful running , register and deploy of the external application `openproject-nextcloud-app`, the external application can be accessed with the url: From 32e1be0b472fd7f6682b88971d46e7325eadf5c7 Mon Sep 17 00:00:00 2001 From: Sagar Date: Wed, 18 Sep 2024 09:57:15 +0545 Subject: [PATCH 3/5] review address Signed-off-by: Sagar --- README.md | 23 +++++++++++++---------- lib/main.py | 6 +++--- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 9e0bf1a..2ea9fe4 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ For the manual installation of `OpenProject` as an external application of `Next ### 1. Install `app_api` application -Assuming you’re in the apps folder of Nextcloud with command git: +Assuming you’re in the apps folder directory: - Clone ```bash - git clone https://github.com/cloud-py-api/app_api.git + git clone https://github.com/nextcloud/app_api.git ``` - build ```bash @@ -17,7 +17,6 @@ Assuming you’re in the apps folder of Nextcloud with command git: npm ci && npm run dev ``` - Enable the `app_api` - ```bash # Assuming you’re in nextcloud server root directory sudo -u www-data php occ a:e app_api @@ -26,7 +25,7 @@ Assuming you’re in the apps folder of Nextcloud with command git: ### 2. Register deploy daemons (In Nextcloud) - Navigate to `Administration Settings > AppAPI` -- Click register daemons +- Click `Register Daemon` - Select `Manual Install` for Daemon configuration template - Put `manual_install` for name and display name - Deployment method as `manual-install` @@ -40,15 +39,13 @@ After the setup, run `OpenProject` locally with the given command line. >NOTE: while running the below bash command line for `NC_SUB_FOLDER` put the sub folder path if you are running nextcloud in a sub folder else do remove it. ```bash -OPENPROJECT_AUTHENTICATION_GLOBAL__BASIC__AUTH_USER=apiadmin \ -OPENPROJECT_AUTHENTICATION_GLOBAL__BASIC__AUTH_PASSWORD=apiadmin \ +# the reason to set relative path with NC_SUB_FOLDER is it makes easy to change when there is redirection url in response OPENPROJECT_RAILS__RELATIVE__URL__ROOT=//index.php/apps/app_api/proxy/openproject-nextcloud-app \ -OPENPROJECT_2FA_DISABLED=false \ foreman start -f Procfile.dev ``` ### 4. Configure and Run External `openproject-nextcloud-app` application -Assuming you’re in the apps folder of Nextcloud with command git: +Assuming you’re in the apps folder directory: - Clone ```bash @@ -56,8 +53,8 @@ Assuming you’re in the apps folder of Nextcloud with command git: ``` - Configure script before running external app ```bash - cd openproject-nextcloud-app - cp ex_app_run_script.sh.example ex_app_run_script.sh + cd openproject-nextcloud-app + cp ex_app_run_script.sh.example ex_app_run_script.sh ``` Once you have copied the script to run the external application, configure the following environments @@ -73,6 +70,12 @@ Assuming you’re in the apps folder of Nextcloud with command git: - OP_BACKEND_URL is the url in which `OpenProject` is up and running - NEXTCLOUD_URL the url in which `Nextcloud` is up and running +- Install required Python packages to run external application `openproject-nextcloud-app` + ```bash + # Make sure that you have python3 installed in your local system + python3 -m pip install -r requirements.txt + ``` + - Run external application with the script ```bash bash ex_app_run_script.sh diff --git a/lib/main.py b/lib/main.py index 9fca98e..5aadde3 100644 --- a/lib/main.py +++ b/lib/main.py @@ -113,7 +113,7 @@ async def proxy_request_to_server(request: Request, path: str): headers=headers, ) else: - # for getting oauth token the content-type 'application/x-www-form-urlencoded' sems to be required + # for getting oauth token the content-type 'application/x-www-form-urlencoded' seems to be required if request.method == 'POST' and "/oauth/token" in url: headers.update({'content-type': 'application/x-www-form-urlencoded'}) response = await client.request( @@ -132,10 +132,10 @@ async def proxy_request_to_server(request: Request, path: str): response.headers["location"] = redirect_url response.status_code = 200 elif "oauth/authorize" in url: - response.headers["location"] = response.headers["location"] + return response elif "apps/oauth2/authorize" in response.headers["location"]: - response.headers["location"] = response.headers["location"] response.status_code = 200 + return response else: headers["content-length"] = "0" response = await handle_redirects( From 4ce4930673cf838fe28d15c98e06e2530e7b6a46 Mon Sep 17 00:00:00 2001 From: Sagar Date: Mon, 30 Sep 2024 09:55:56 +0545 Subject: [PATCH 4/5] Review address Signed-off-by: Sagar --- README.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 2ea9fe4..772d80b 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Assuming you’re in the apps folder directory: Set up and build `OpenProject` locally following [OpenProject Development Setup](https://www.openproject.org/docs/development/development-environment/) After the setup, run `OpenProject` locally with the given command line. ->NOTE: while running the below bash command line for `NC_SUB_FOLDER` put the sub folder path if you are running nextcloud in a sub folder else do remove it. +>NOTE: If you are running Nextcloud in a sub folder replace `NC_SUB_FOLDER` with the path name, otherwise remove it. ```bash # the reason to set relative path with NC_SUB_FOLDER is it makes easy to change when there is redirection url in response @@ -58,17 +58,17 @@ Assuming you’re in the apps folder directory: ``` Once you have copied the script to run the external application, configure the following environments - - APP_ID is the application id of the external app - - APP_PORT is port for the external app - - APP_HOST is the host for the external app - - APP_SECRET is the secret required for the communication between external app and nextcloud - - APP_VERSION is the version of external app - - AA_VERSION is the app_api version used - - EX_APP_VERSION is the version of external app - - EX_APP_ID is the application id of the external app - - NC_SUB_FOLDER is the subfolder in which nextcloud is running (make sure to use same in OPENPROJECT_RAILS__RELATIVE__URL__ROOT while running openproject) - - OP_BACKEND_URL is the url in which `OpenProject` is up and running - - NEXTCLOUD_URL the url in which `Nextcloud` is up and running + - `APP_ID` is the application id of the external app + - `APP_PORT` is port for the external app + - `APP_HOST` is the host for the external app + - `APP_SECRET` is the secret required for the communication between external app and nextcloud + - `APP_VERSION` is the version of external app + - `AA_VERSION` is the app_api version used + - `EX_APP_VERSION` is the version of external app + - `EX_APP_ID` is the application id of the external app + - `NC_SUB_FOLDER` is the subfolder in which nextcloud is running (make sure to use same in OPENPROJECT_RAILS__RELATIVE__URL__ROOT while running openproject) + - `OP_BACKEND_URL` is the url in which `OpenProject` is up and running + - `NEXTCLOUD_URL` the url in which `Nextcloud` is up and running - Install required Python packages to run external application `openproject-nextcloud-app` ```bash @@ -100,10 +100,10 @@ Assuming you’re in nextcloud server root directory \"headers_to_exclude\":[]}]}" \ --force-scopes --wait-finish ``` - In the above bash command use the same value for `EX_APP_ID`, `EX_APP_VERSION`, `APP_SECRET`, and `APP_PORT` used while running external app `openproject-nextcloud-app` + In the above bash command use the same value for `EX_APP_ID`, `EX_APP_VERSION`, `APP_SECRET`, and `APP_PORT` as used while running external app `openproject-nextcloud-app` -Upon successful running , register and deploy of the external application `openproject-nextcloud-app`, the external application can be accessed with the url: +Now OpenProject can be reached on: ```bash http://${APP_HOST}/${NC_SUB_FOLDER}/index.php/apps/app_api/proxy/openproject-nextcloud-app ``` From 856a29e586b7b46a2a12f9ad75de39e1ce21dd14 Mon Sep 17 00:00:00 2001 From: Sagar Date: Mon, 30 Sep 2024 12:41:16 +0545 Subject: [PATCH 5/5] Remove specail header for auth/token endpoint (fixed) Signed-off-by: Sagar --- lib/main.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/main.py b/lib/main.py index 5aadde3..40026d1 100644 --- a/lib/main.py +++ b/lib/main.py @@ -113,9 +113,6 @@ async def proxy_request_to_server(request: Request, path: str): headers=headers, ) else: - # for getting oauth token the content-type 'application/x-www-form-urlencoded' seems to be required - if request.method == 'POST' and "/oauth/token" in url: - headers.update({'content-type': 'application/x-www-form-urlencoded'}) response = await client.request( method=request.method, url=url,