diff --git a/README.md b/README.md index 0f12c0c..772d80b 100644 --- a/README.md +++ b/README.md @@ -1 +1,110 @@ -# 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 directory: + +- Clone + ```bash + git clone https://github.com/nextcloud/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 Daemon` +- 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: 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 +OPENPROJECT_RAILS__RELATIVE__URL__ROOT=//index.php/apps/app_api/proxy/openproject-nextcloud-app \ +foreman start -f Procfile.dev +``` + +### 4. Configure and Run External `openproject-nextcloud-app` application +Assuming you’re in the apps folder directory: + +- Clone + ```bash + git clone 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 + +- 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 + ``` + +### 5. 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` as used while running external app `openproject-nextcloud-app` + + +Now OpenProject can be reached on: +```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..40026d1 100644 --- a/lib/main.py +++ b/lib/main.py @@ -1,35 +1,181 @@ -"""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: + 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: + return response + elif "apps/oauth2/authorize" in response.headers["location"]: + response.status_code = 200 + return response + 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