Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 110 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,110 @@
# Nextcloud-OpenProject-App repository
# 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=/<NC_SUB_FOLDER>/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\":\"<EX_APP_ID>\",
\"name\":\"<EX_APP_ID>\",
\"daemon_config_name\":\"manual_install\",
\"version\":\"<EX_APP_VERSION>\",
\"secret\":\"<APP_SECRET>\",
\"scopes\":[\"ALL\"],
\"port\":<APP_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
```

15 changes: 15 additions & 0 deletions ex_app_run_script.sh.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/bin/bash

export APP_ID="openproject-nextcloud-app"
export APP_PORT="9030"
export APP_HOST="localhost"
export APP_SECRET="<app-secret>"
export APP_VERSION="<ex-app-version>"
export AA_VERSION="<app-api-version>"
export EX_APP_VERSION="<ex-app-version>"
export EX_APP_ID="openproject-nextcloud-app"
export NC_SUB_FOLDER="<nc-sub-folder-path>"
export OP_BACKEND_URL="http://<openproject-host>:<openproject-port>/${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
178 changes: 162 additions & 16 deletions lib/main.py
Original file line number Diff line number Diff line change
@@ -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")