Skip to content

Commit 21762c6

Browse files
authored
Merge pull request #3 from JankariTech/dockerizeOpenProjectAsExxApp
Run `OpenProject` as an external app of Nextcloud with manual setup
2 parents 2958a3a + 856a29e commit 21762c6

File tree

3 files changed

+287
-17
lines changed

3 files changed

+287
-17
lines changed

README.md

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,110 @@
1-
# Nextcloud-OpenProject-App repository
1+
# OpenProject as Nextcloud's External App
2+
3+
## Manual Installation
4+
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.
5+
6+
### 1. Install `app_api` application
7+
8+
Assuming you’re in the apps folder directory:
9+
10+
- Clone
11+
```bash
12+
git clone https://github.com/nextcloud/app_api.git
13+
```
14+
- build
15+
```bash
16+
cd app_api
17+
npm ci && npm run dev
18+
```
19+
- Enable the `app_api`
20+
```bash
21+
# Assuming you’re in nextcloud server root directory
22+
sudo -u www-data php occ a:e app_api
23+
```
24+
25+
### 2. Register deploy daemons (In Nextcloud)
26+
27+
- Navigate to `Administration Settings > AppAPI`
28+
- Click `Register Daemon`
29+
- Select `Manual Install` for Daemon configuration template
30+
- Put `manual_install` for name and display name
31+
- Deployment method as `manual-install`
32+
- Daemon host as `localhost`
33+
- Click Register
34+
35+
### 3. Running OpenProject locally
36+
Set up and build `OpenProject` locally following [OpenProject Development Setup](https://www.openproject.org/docs/development/development-environment/)
37+
After the setup, run `OpenProject` locally with the given command line.
38+
39+
>NOTE: If you are running Nextcloud in a sub folder replace `NC_SUB_FOLDER` with the path name, otherwise remove it.
40+
41+
```bash
42+
# the reason to set relative path with NC_SUB_FOLDER is it makes easy to change when there is redirection url in response
43+
OPENPROJECT_RAILS__RELATIVE__URL__ROOT=/<NC_SUB_FOLDER>/index.php/apps/app_api/proxy/openproject-nextcloud-app \
44+
foreman start -f Procfile.dev
45+
```
46+
47+
### 4. Configure and Run External `openproject-nextcloud-app` application
48+
Assuming you’re in the apps folder directory:
49+
50+
- Clone
51+
```bash
52+
git clone https://github.com/JankariTech/openproject-nextcloud-app.git
53+
```
54+
- Configure script before running external app
55+
```bash
56+
cd openproject-nextcloud-app
57+
cp ex_app_run_script.sh.example ex_app_run_script.sh
58+
```
59+
Once you have copied the script to run the external application, configure the following environments
60+
61+
- `APP_ID` is the application id of the external app
62+
- `APP_PORT` is port for the external app
63+
- `APP_HOST` is the host for the external app
64+
- `APP_SECRET` is the secret required for the communication between external app and nextcloud
65+
- `APP_VERSION` is the version of external app
66+
- `AA_VERSION` is the app_api version used
67+
- `EX_APP_VERSION` is the version of external app
68+
- `EX_APP_ID` is the application id of the external app
69+
- `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)
70+
- `OP_BACKEND_URL` is the url in which `OpenProject` is up and running
71+
- `NEXTCLOUD_URL` the url in which `Nextcloud` is up and running
72+
73+
- Install required Python packages to run external application `openproject-nextcloud-app`
74+
```bash
75+
# Make sure that you have python3 installed in your local system
76+
python3 -m pip install -r requirements.txt
77+
```
78+
79+
- Run external application with the script
80+
```bash
81+
bash ex_app_run_script.sh
82+
```
83+
84+
### 5. Register and deploy external application `openproject-nextcloud-app` in Nextcloud's external apps
85+
86+
Assuming you’re in nextcloud server root directory
87+
88+
- Register and deploy external application `openproject-nextcloud-app`
89+
```bash
90+
sudo -u www-data php occ app_api:app:register openproject-nextcloud-app manual_install --json-info \
91+
"{\"id\":\"<EX_APP_ID>\",
92+
\"name\":\"<EX_APP_ID>\",
93+
\"daemon_config_name\":\"manual_install\",
94+
\"version\":\"<EX_APP_VERSION>\",
95+
\"secret\":\"<APP_SECRET>\",
96+
\"scopes\":[\"ALL\"],
97+
\"port\":<APP_PORT>,
98+
\"routes\": [{\"url\":\".*\",\"verb\":\"GET, POST, PUT, DELETE, HEAD, PATCH, OPTIONS, TRACE\",
99+
\"access_level\":1,
100+
\"headers_to_exclude\":[]}]}" \
101+
--force-scopes --wait-finish
102+
```
103+
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`
104+
105+
106+
Now OpenProject can be reached on:
107+
```bash
108+
http://${APP_HOST}/${NC_SUB_FOLDER}/index.php/apps/app_api/proxy/openproject-nextcloud-app
109+
```
110+

ex_app_run_script.sh.example

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/bin/bash
2+
3+
export APP_ID="openproject-nextcloud-app"
4+
export APP_PORT="9030"
5+
export APP_HOST="localhost"
6+
export APP_SECRET="<app-secret>"
7+
export APP_VERSION="<ex-app-version>"
8+
export AA_VERSION="<app-api-version>"
9+
export EX_APP_VERSION="<ex-app-version>"
10+
export EX_APP_ID="openproject-nextcloud-app"
11+
export NC_SUB_FOLDER="<nc-sub-folder-path>"
12+
export OP_BACKEND_URL="http://<openproject-host>:<openproject-port>/${NC_SUB_FOLDER}/index.php/apps/app_api/proxy/openproject-nextcloud-app"
13+
export NEXTCLOUD_URL="http://${APP_HOST}/${NC_SUB_FOLDER}/index.php"
14+
15+
python3.10 lib/main.py

lib/main.py

Lines changed: 162 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,181 @@
1-
"""Simplest example."""
2-
1+
import typing
2+
import httpx
3+
import os
4+
from urllib.parse import urlparse, parse_qs
5+
import urllib.parse
6+
from urllib.parse import urlencode
7+
import json
8+
from starlette.responses import Response, JSONResponse
39
from contextlib import asynccontextmanager
4-
5-
from fastapi import FastAPI
10+
from fastapi import FastAPI, Request, BackgroundTasks, Depends
11+
from fastapi.middleware.cors import CORSMiddleware
612
from nc_py_api import NextcloudApp
7-
from nc_py_api.ex_app import AppAPIAuthMiddleware, LogLvl, run_app, set_handlers
13+
from nc_py_api.ex_app import AppAPIAuthMiddleware, LogLvl, run_app, nc_app
14+
from nc_py_api.ex_app.integration_fastapi import fetch_models_task
815

916

1017
@asynccontextmanager
1118
async def lifespan(app: FastAPI):
12-
set_handlers(app, enabled_handler)
1319
yield
1420

1521

1622
APP = FastAPI(lifespan=lifespan)
17-
APP.add_middleware(AppAPIAuthMiddleware) # set global AppAPI authentication middleware
23+
APP.add_middleware(AppAPIAuthMiddleware)
24+
APP.add_middleware(
25+
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
26+
)
1827

1928

2029
def enabled_handler(enabled: bool, nc: NextcloudApp) -> str:
21-
# This will be called each time application is `enabled` or `disabled`
22-
# NOTE: `user` is unavailable on this step, so all NC API calls that require it will fail as unauthorized.
23-
print(f"enabled={enabled}")
30+
print(f"{nc.app_cfg.app_name}={enabled}")
2431
if enabled:
25-
nc.log(LogLvl.WARNING, f"Hello from {nc.app_cfg.app_name} :)")
32+
nc.log(LogLvl.INFO, f"{nc.app_cfg.app_name} is enabled")
2633
else:
27-
nc.log(LogLvl.WARNING, f"Bye bye from {nc.app_cfg.app_name} :(")
28-
# In case of an error, a non-empty short string should be returned, which will be shown to the NC administrator.
34+
nc.log(LogLvl.INFO, f"{nc.app_cfg.app_name} is disabled")
2935
return ""
3036

3137

38+
@APP.get("/heartbeat")
39+
async def heartbeat_callback():
40+
return JSONResponse(content={"status": "ok"})
41+
42+
43+
@APP.post("/init")
44+
async def init_callback(
45+
b_tasks: BackgroundTasks, nc: typing.Annotated[NextcloudApp, Depends(nc_app)]
46+
):
47+
b_tasks.add_task(fetch_models_task, nc, {}, 0)
48+
return JSONResponse(content={})
49+
50+
51+
@APP.put("/enabled")
52+
async def enabled_callback(
53+
enabled: bool, nc: typing.Annotated[NextcloudApp, Depends(nc_app)]
54+
):
55+
return JSONResponse(content={"error": enabled_handler(enabled, nc)})
56+
57+
58+
@APP.api_route(
59+
"/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "HEAD", "PATCH", "OPTIONS"]
60+
)
61+
async def proxy_Requests(_request: Request, path: str):
62+
response = await proxy_request_to_server(_request, path)
63+
64+
headers = dict(response.headers)
65+
headers.pop("transfer-encoding", None)
66+
headers.pop("content-encoding", None)
67+
headers["content-length"] = str(response.content.__len__())
68+
headers["content-security-policy"] = (
69+
"default-src * 'unsafe-inline' 'unsafe-eval' data: blob:;"
70+
)
71+
72+
return Response(
73+
content=response.content,
74+
status_code=response.status_code,
75+
headers=headers,
76+
)
77+
78+
79+
async def proxy_request_to_server(request: Request, path: str):
80+
async with httpx.AsyncClient(follow_redirects=False) as client:
81+
backend_url = get_backend_url()
82+
url = f"{backend_url}/{path}"
83+
headers = {}
84+
for k, v in request.headers.items():
85+
# NOTE:
86+
# - remove 'host' to make op routes work
87+
# - remove 'origin' to validate csrf
88+
if k == "host" or k == "origin":
89+
continue
90+
headers[k] = v
91+
92+
if request.method == "GET":
93+
params=request.query_params
94+
# A referrer header is required when we request to '/work_packages/menu' enpoint
95+
# Currently the browser does not provide the referer header so it has been put through proxy
96+
# Also it works even referrer is empty
97+
if url.endswith("/work_packages/menu"):
98+
headers.update({'referer': ''})
99+
100+
if "/project_storages/new" in url :
101+
# when requesting the storate_id is stripped in the proxy (issue: https://github.com/cloud-py-api/app_api/issues/384).
102+
# This piece of code modifies the query param to add missing storage_id.
103+
query_params = dict(params)
104+
if 'storages_project_storage[]' in query_params:
105+
value = query_params['storages_project_storage[]']
106+
new_key = 'storages_project_storage[storage_id]'
107+
query_params[new_key] = value
108+
del query_params['storages_project_storage[]']
109+
params = urlencode(query_params, doseq=True)
110+
response = await client.get(
111+
url,
112+
params=params,
113+
headers=headers,
114+
)
115+
else:
116+
response = await client.request(
117+
method=request.method,
118+
url=url,
119+
params=request.query_params,
120+
headers=headers,
121+
content=await request.body(),
122+
)
123+
124+
125+
if response.is_redirect and not response.status_code == 304:
126+
if "location" in response.headers and "proxy/openproject-nextcloud-app" in response.headers["location"]:
127+
redirect_path = urlparse(response.headers["location"]).path
128+
redirect_url = get_nc_url() + redirect_path
129+
response.headers["location"] = redirect_url
130+
response.status_code = 200
131+
elif "oauth/authorize" in url:
132+
return response
133+
elif "apps/oauth2/authorize" in response.headers["location"]:
134+
response.status_code = 200
135+
return response
136+
else:
137+
headers["content-length"] = "0"
138+
response = await handle_redirects(
139+
client,
140+
request.method if response.status_code == 307 else "GET",
141+
response.headers["location"],
142+
headers,
143+
)
144+
return response
145+
146+
147+
async def handle_redirects(
148+
client: httpx.AsyncClient,
149+
method: str,
150+
url: str,
151+
headers: dict,
152+
):
153+
response = await client.request(
154+
method=method,
155+
url=url,
156+
headers=headers,
157+
)
158+
159+
if response.is_redirect:
160+
return await handle_redirects(
161+
client,
162+
method if response.status_code == 307 else "GET",
163+
response.headers["location"],
164+
headers,
165+
)
166+
167+
return response
168+
169+
170+
def get_backend_url():
171+
return os.getenv("OP_BACKEND_URL", "http://localhost:3000")
172+
173+
174+
def get_nc_url():
175+
nc_url = os.getenv("NEXTCLOUD_URL", "http://localhost/index.php")
176+
url = urlparse(nc_url)
177+
return f"{url.scheme}://{url.netloc}"
178+
179+
32180
if __name__ == "__main__":
33-
# Wrapper around `uvicorn.run`.
34-
# You are free to call it directly, with just using the `APP_HOST` and `APP_PORT` variables from the environment.
35-
run_app("main:APP", log_level="trace")
181+
run_app("main:APP", log_level="trace")

0 commit comments

Comments
 (0)