qwc-services による Web GIS の構築 #4 独自ページを追加

QGIS Server と QGIS Web Client 2 (QWC2) を中核とする統合的 Web GIS エコ・システムである qwc-services によって Web GIS を構築する作業記録の第4回です。

第1回では、`qwc-docker` によって `qwc-services` をインストールし、`MapCache` とも連携させて、QGIS のプロジェクトを Web 地図として表示するところまでやりました。

- [qwc-services による Web GIS の構築 #1 qwc-docker](https://qiita.com/softark/items/bb179315d404fdd3cf9f)

第2回では、ユーザの認証と権限管理について調べて、地図やレイヤを閲覧できるユーザを限定したり、ビューワの機能を使用できるユーザを限定したりする方法を確認しました。

- [qwc-services による Web GIS の構築 #2 認証と権限](https://qiita.com/softark/items/e307645aebcd5366d16b)

第3回では、qwc-services によって実現される「編集」関連の機能に不可欠なデータベース環境である `PostGIS` を使えるようにしました。

- [qwc-services による Web GIS の構築 #3 PostGIS](https://qiita.com/softark/items/74861df6a6ebfd359eaa)

今回は `Python` のマイクロ・ウェブ・フレームワークである `Flask` を使って `QWC2` ビューワとは独立したページを作成し、Web GIS 全体としての使い勝手を向上させたいと思います。

## 1. Docker コンテナで Flask を動かす

まず、`Flask` のためのディレクトリ `qwc-docker/qwc-flask` を作成します。

```bash
mkdir qwc-docker/qwc-flask
```

以下の内容で `qwc-docker/qwc-flask/Dokerfile` を作成します。

```docker:Dockerfile
FROM python:3.9
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["gunicorn", "-b", "0.0.0.0:8081", "app:app"]
```

同じディレクトリに `requirements.txt` も作成します。

```txt:requirements.txt
flask
requests
werkzeug
PyJWT
gunicorn
```

```bash
sudo dnf install python3
sudo dnf install python3-pip
```

`qwc-docker/docker-compose.yml` に `qwc-flask` のエントリを追加します。

```diff_yaml:docker-compose.yml
      ...
      qwc-api-gateway:
        image: nginx:1.27
        ports:
          # NOTE: The port the qwc application runs on. You can choose another port instead of 8088.
          - "8088:80"
        volumes:
          - ./api-gateway/nginx.conf:/etc/nginx/conf.d/default.conf:ro
        depends_on:
          ...
    
+     qwc-flask:
+       build:
+         context: ./qwc-flask
+       container_name: qwc-flask
+       environment:
+         <<: *qwc-service-variables
+       ports:
+         - "8081:8081"
+       volumes:
+         - ./qwc-flask:/app
+         - ./volumes/config/default/permissions.json:/app/permissions.json:ro
```

Docker コンテナを起動します。このとき `--build` コマンドによって `qwc-flask` のイメージがビルドされます。

:::note warn
`qwc-flask` の `Docker` イメージはレディーメイドのものではありませんので、構成を変更するたびにビルドする必要があります。
:::

```bash
docker compose up --build -d
```

## 2. ディレクトリとファイルの構成

`qwc-docker/qwc-flask` ディレクトリの構成は以下のようにします。

```
qwc-docker
   +--- qwc-flask
           +--- Dockerfile
           +--- requirements.txt
           +--- permissions.json
           +--- app.py
           +--- templates
                    +--- base.html
                    +--- index.html
                    +--- manual.html
           +--- static
                    +--- assets
                            +--- css
                            +--- img
                            +--- webfonts
                                    
```

- `Dockerfile` ... `Docker` イメージをビルドし実行するための設定
- `requirements.txt` ... `Dockerfile` から参照される依存パッケージのリスト
- `permissions.txt` ... `QWC2 / QWC Services` のユーザ権限情報
    - `qwc-docker/volumes/config/default/permissions.json` をボリューム・マウントしたもの
    - `QWC Admin` の "Generate Configuration" ボタンによって `QWC2` の構成情報が再作成されるたびに更新される
    - `Flask` によるページでユーザ権限管理のために参照する
- `templates` ... `HTML` テンプレート
    - `base.html` ... 各ページ共通の枠組み
    - `index.html` ... トップ・ページ
    - `manual.html` ... 操作マニュアルのページ
    - ... 必要に応じて追加
- `static` ... 静的なリソースを保持するディレクトリ

## 3. app.py

`app.py` がメインの `Python` スクリプトです。

以下のような内容です。

```python
# -*- coding: utf-8 -*-
from flask import Flask, request, jsonify, render_template, url_for
import requests
import jwt
import json
import os
from werkzeug.middleware.proxy_fix import ProxyFix

app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_prefix=1)

PERMISSIONS_FILE = "/app/permissions.json"

JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "dev-secret")
JWT_ACCESS_COOKIE_NAME = os.environ.get("JWT_ACCESS_COOKIE_NAME", "access_token_cookie")

def get_username():
    token = request.cookies.get(JWT_ACCESS_COOKIE_NAME)  # ブラウザの Cookie から JWT を取得
    if not token:
        return "guest"
    try:
        decoded = jwt.decode(token, JWT_SECRET_KEY, algorithms=["HS256"])
        return decoded.get("qwc_identity",{}).get("username", "guest")
    except jwt.exceptions.InvalidTokenError as e:
        print("JWT 解析失敗:", e)
        return "guest"

def get_user_roles(username):
    try:
        with open(PERMISSIONS_FILE) as f:
            data = json.load(f)
    except Exception as e:
        print("権限設定ファイル読込失敗:", e)
        return []

    roles = set()

    # ユーザーを探す
    user_entry = next((user for user in data.get("users", []) if user["name"] == username), None)
    if not user_entry:
        return []

    # 1. 直接割り当てられたロール
    roles.update(user_entry.get("roles", []))

    # 2. 所属グループ経由のロール
    user_groups = user_entry.get("groups", [])
    for group in user_groups:
        group_entry = next((g for g in data.get("groups", []) if g["name"] == group), None)
        if group_entry:
            roles.update(group_entry.get("roles", []))
    return sorted(roles)

@app.route('/')
def index():
    username = get_username()
    roles = get_user_roles(username)
    return render_template('index.html', username=username, roles=roles)

@app.route('/manual')
def manual():
    username = get_username()
    return render_template('manual.html', username=username)

```

### 3-1. nginx によるリバース・プロキシへの対応

```
from werkzeug.middleware.proxy_fix import ProxyFix
...
app.wsgi_app = ProxyFix(app.wsgi_app, x_prefix=1)
```

この部分は、`nginx` によるリバース・プロキシによって、`http://localhost:8081/` が `https://webgis.mydomain/home` など、`/` 以外のパスに接続されることを考慮した対策です。

この対策の有無によって、例えば、`url_for('static', filename='assets/img/logo.png')` が返すパスが異なってきます。

| 対策 | 結果 |
|:-:|---|
| 無 | '/static/assets/img/logo.png' |
| 有 | '/home/static/assets/img/logo.png' |

#### 3-1-1. nginx の設定

`nginx` のリバース・プロキシの設定は次のようにします。

```nginx
    # flask
    location /home/ {
        proxy_set_header    Host                $host;
        proxy_set_header    X-Forwarded-Prefix  /home;
        proxy_pass          http://localhost:8081/;
        proxy_redirect      off;
    }
```

### 3-2. 権限管理用データ

```python
PERMISSIONS_FILE = "/app/permissions.json"

JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "dev-secret")
JWT_ACCESS_COOKIE_NAME = os.environ.get("JWT_ACCESS_COOKIE_NAME", "access_token_cookie")
```

この部分は、権限管理用のデータを設定している所です。

- `PERMISSION_FILE` ... `/app/permissions.json` は、`qwc-docker/volumes/config/default/permissions.json` がボリューム・マウントされたもの
    - ユーザ、グループ、そして、ロールの一覧とその関係を保持している
    - QWC Admin の "Generate Configuration" ボタンによって QWC2 の構成情報が再作成されるたびに更新される
- `JWT_SECRET_KEY` ... `JWT` 秘密鍵を環境変数から読み出そうとしている
    - `docker-compose.yml` の冒頭に記載される内容が `qwc-flask` でも共有される設定
    - その結果、クッキーをエンコードして発行するサービスとクッキーをデコードして読み込むサービスの間で秘密鍵を共有できる
    - 内容は `.env` ファイルから読み出される
- `JWT_ACCESS_COOKIE_NAME` ... デフォルト値は `"access_token_cookie"`
    - 他のサービスでもこのデフォルト値が使われている

### 3-3. ユーザ名の取得

```python
def get_username():
    token = request.cookies.get(JWT_ACCESS_COOKIE_NAME)  # ブラウザの Cookie から JWT を取得
    if not token:
        return "guest"
    try:
        decoded = jwt.decode(token, JWT_SECRET_KEY, algorithms=["HS256"])
        return decoded.get("qwc_identity",{}).get("username", "guest")
    except jwt.exceptions.InvalidTokenError as e:
        print("JWT 解析失敗:", e)
        return "guest"
```

ブラウザのクッキーから `JWT` (`JSON Web Token`) を読み出して、ユーザ名を取得しています。

ログインしていない場合や、`JWT` のデコードに失敗した場合は、`"guest"` を返します。

### 3-4. ユーザのロールを取得

```python
def get_user_roles(username):
    try:
        with open(PERMISSIONS_FILE) as f:
            data = json.load(f)
    except Exception as e:
        print("権限設定ファイル読込失敗:", e)
        return []

    roles = set()

    # ユーザーを探す
    user_entry = next((user for user in data.get("users", []) if user["name"] == username), None)
    if not user_entry:
        return []

    # 1. 直接割り当てられたロール
    roles.update(user_entry.get("roles", []))

    # 2. 所属グループ経由のロール
    user_groups = user_entry.get("groups", [])
    for group in user_groups:
        group_entry = next((g for g in data.get("groups", []) if g["name"] == group), None)
        if group_entry:
            roles.update(group_entry.get("roles", []))

    return sorted(roles)
```

`PERMISSIONS_FILE` を読んで、ユーザに割り当てられているロールを返しています。

ユーザは、直接にロールを割り当てられることもあれば、所属グループ経由でロールを割り当てられることもあります。また、一人のユーザに複数のロールが割り当てられることもあります。上記の関数はそれらの場合も想定して、ユーザに割り当てられたロールを漏れなく重複なくリストアップします。

### 3-5. index ルート

```python
@app.route('/')
def index():
    username = get_username()
    roles = get_user_roles(username)
    return render_template('index.html', username=username, roles=roles)
```

ユーザ名とロールを取得して `index.html` テンプレートをレンダリングします。

### 3-6. manual ルート

```python
@app.route('/manual')
def manulal():
    username = get_username()
    roles = get_user_roles(username)
    return render_template('manual.html', username=username, roles=roles)
```

ユーザ名とロールを取得して `manual.html` テンプレートをレンダリングします。

### 3-7. base.html テンプレート

`base.html` テンプレートは、`index.html` および `manual.html` から呼び出される共通の枠組みです。

```html
<html lang="ja" class="h100"">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, minimal-ui">
    <meta name="HandheldFriendly" content="true">
    <meta name="mobile-web-app-capable" content="yes">
    <link rel="apple-touch-icon" href="{{ url_for('static', filename='assets/img/app_icon.png') }}">
    <link rel="apple-touch-icon" sizes="72x72" href="{{ url_for('static', filename='assets/img/app_icon_72.png') }}">
    <link rel="apple-touch-icon" sizes="114x114" href="{{ url_for('static', filename='assets/img/app_icon_114.png') }}">
    <link rel="apple-touch-icon" sizes="144x144" href="{{ url_for('static', filename='assets/img/app_icon_144.png') }}">
    <link rel="icon" href="{{ url_for('static', filename='assets/img/favicon.ico') }}">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" ... >
    <link href="{{ url_for('static', filename='assets/css/site.css')}}" rel="stylesheet">
    <link href="{{ url_for('static', filename='assets/css/all.min.css')}}" rel="stylesheet">
    {% block title %}{% endblock %}
</head>
<body class="d-flex flex-column h-100 overflow-y-scroll">
<header class="no-print">
    <nav id="w27" class="navbar navbar-expand-lg fixed-top"
         style="border-bottom:1px solid #CCCCCC; background-color:white">
        <div class="container">
            <a class="navbar-brand" href="{{url_for('index')}}"><img
                    src="{{ url_for('static', filename='assets/img/logo.svg') }}" height="46" alt=""
                    style="margin-top:-20px; margin-bottom:-16px"> 岩座神地理情報システム</a>
            <button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#w27-collapse"
                    aria-controls="w27-collapse" aria-expanded="false" aria-label="Toggle navigation"><span
                    class="navbar-toggler-icon"></span></button>
            <div id="w27-collapse" class="collapse navbar-collapse">
                <ul id="w28" class="navbar-nav me-auto nav">
                    <li class="nav-item"><a class="nav-link" href="{{ url_for('index') }}"><i class="fa-solid fa-home"></i> HOME</a></li>
                    <li class="nav-item"><a class="nav-link" href="{{ url_for('manual') }}"><i class="fa-solid fa-circle-question"></i> マニュアル</a></li>
                    {% if 'admin' in roles %}
                    <li class="nav-item"><a class="nav-link" href="/qwc_admin"><i class="fa-solid fa-wrench"></i>QWC Admin</a></li>
                    <li class="nav-item"><a class="nav-link" href="/pga"><i class="fa-solid fa-wrench"></i> pgAdmin4</a></li>
                    {% endif %}
                </ul>
                <ul id="w29" class="navbar-nav nav">
                    <li class="nav-item" style="margin-top:8px; margin-right:8px"><i class="fa-solid fa-user"></i>{{username}}</li>
                    {% if username == 'guest' %}
                    <li class="nav-item"><a class="nav-link" href="/auth/login?url={{url_for('index')}}"><i class="fa-solid fa-right-to-bracket"></i> ログイン</a></li>
                    {% else %}
                    <li class="nav-item"><a class="nav-link" href="/auth/logout?url={{url_for('index')}}"><i class="fa-solid fa-right-from-bracket"></i> ログアウト</a></li>
                    {% endif %}
                </ul>
            </div>
        </div>
    </nav>
</header>

<main role="main" class="flex-shrink-0">
    <div class="container">
        {% block contents %}{% endblock %}
    </div>
</main>

<footer class="footer mt-auto py-3 text-muted no-print">
    <div class="container">
        <p class="float-start">i-GIS 岩座神地理情報システム</p>
        <p class="float-end">maintained by <a href="https://softark.net" target="_blank">softark</a></p>
    </div>
</footer>

<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js" ...></script>
</body>
</html>
````

`Bootstrap 5` と `FontAwesome` を使ってスタイリングしているため、ソースが少し長くなっていますが、下記の点に注目して読んでみて下さい。

- `url_for()`
> ```html
> <link rel="icon" href="{{ url_for('static', filename='assets/img/favicon.ico') }}">
> ```

- タイトルのプレースホルダ
> ```html
> {% block title %}{% endblock %}
>```

- ロールに応じて、メニュー項目の表示・非表示を制御
    - 例えば、`admin` ロールを持つユーザにだけ、特定のメニューを表示
> ```html
> {% if 'admin' in roles %}
> <li class="nav-item"><a class="nav-link" href="/qwc_admin"><i class="fa-solid fa-wrench"></i>QWC Admin</a></li>
> <li class="nav-item"><a class="nav-link" href="/pga"><i class="fa-solid fa-wrench"></i> pgAdmin4</a></li>
> {% endif %}
> ```

- ユーザ名を表示
> ```html
> <li class="nav-item" style="margin-top:8px; margin-right:8px"><i class="fa-solid fa-user"></i>{{username}}</li>
> ```

- ユーザが `"guest"` なら「ログイン」を表示、そうでなければ「ログアウト」を表示
> ```html
> {% if username == 'guest' %}
> <li class="nav-item"><a class="nav-link" href="/auth/login?url=> {{url_for('index')}}"><i class="fa-solid fa-right-to-bracket"></i> ログイン</a></li>
> {% else %}
> <li class="nav-item"><a class="nav-link" href="/auth/logout?url={{url_for('index')}}"><i class="fa-solid fa-right-from-bracket"></i> ログアウト</a>> </li>
> {% endif %}
> ```

- メインのコンテンツのプレースホルダ
> ```html
> {% block contents %}{% endblock %}
> ```

### 3-8. index.html, manual.html テンプレート

これらのテンプレートでは、特に面白いことをしてはいませんので、作りかけの `manual.html` の内容を掲示します。その方が構造が分りやすいでしょう。

```html
{% extends 'base.html' %}

{% block title %}
<title>i-GIS マニュアル</title>
{% endblock %}

{% block contents %}
<div class="h-100 p-3">
    <h1><img src="{{url_for('static', filename='assets/img/logo.svg')}}" height="80" alt="" style="margin-top:-10px;">マニュアル</h1>
    <p>準備中です...</p>
    <div class="body-content">
        <div class="row">
            <div class="col-12">
                <h2 class="h4 border-secondary border-bottom pb-1 mt-1 mb-3">i-GIS の画面</h2>
            </div>
        </div>
    </div>
</div>
{% endblock %}
```

:::note
`Flask` を使うと、比較的短時間で簡単にページを作成することが出来ますし、`Bootstrap` や `FontAwesome` を併用すると、見た目もそれなりに整えることが出来ます。また、上で見たように、`qwc-services` のユーザ権限の情報を簡単に流用することが可能ですので、ポータルや管理画面など `QWC2` ビューワの機能を補うページを作りたい場合は最適解であろうと思います。
:::

## 4. 非 Dockerize

`Docker` のコンテナとして作成した `Flask` アプリですが、後日、非 `Dockerize` して、直接ホスト内で動かすようにしました。

理由は、`Flask` で作成した管理ページから `MapCache` のキャッシュ制御を実行したかったためです。

非 `Dockerize` は比較的簡単にできました。以下、備忘のために手順を記録します。

### 4-1. コンテナを停める

`docker compose down qwc-flask` で `qwc-flask` コンテナを停止。

`docker-compose.yml` を編集して、`qwc-flask` コンテナをコメントアウト。

### 4-2. venv

```bash
cd qwc-flask
python3 -m venv ./venv
source ./venv/bin/activate
```

### 4-3. 必要なパッケージをインストール

```bash
pip install -r requirements.txt
pip install python3-dotenv
```

### 4-4. ソースの修正

`app.py` を以下のように修正します。

```diff_python
    # -*- coding: utf-8 -*-
    from flask import Flask, request, jsonify, render_template, url_for
    import requests
    import jwt
    import json
    import os
    from werkzeug.middleware.proxy_fix import ProxyFix
+   from dotenv import load_dotenv

    app = Flask(__name__)
    app.wsgi_app = ProxyFix(app.wsgi_app, x_prefix=1)
    
+   load_dotenv() # local
+   load_dotenv(dotenv_path="/home/my-account/qwc-docker/.env")
    
-   PERMISSIONS_FILE = "/app/permissions.json"
+   PERMISSIONS_FILE = os.environ.get("PERMISSIONS_FILE", "/home/my-account/qwc-docker/volumes/config/default/permissions.json")
    JWT_SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "dev-secret")
    JWT_ACCESS_COOKIE_NAME = os.environ.get("JWT_ACCESS_COOKIE_NAME", "access_token_cookie")

```

変更のポイントは:

- `qwc-docker` の `.env` ファイルを読み取って環境変数を取得
    - `JWT_SECRET_KEY` 環境変数が重要
- `permissions.json` のパスを変更
    - コンテナ内でボリューム・マウントしていたものを直接に指定

### 4-5. gunicorn のサービス化

`/etc/systemd/system/qwc-flask.service` を下記のように作成します。

```systemd:qwc-flask.service
[Unit]
Description=Flask App via Gunicorn
After=network.target

[Service]
User=my-account
Group=my-account
WorkingDirectory=/home/my-account/qwc-docker/qwc-flask
ExecStart=/home/my-account/qwc-docker/qwc-flask/venv/bin/gunicorn -w 4 -b 127.0.0.1:8081 app:app

[Install]
WantedBy=multi-user.target
```

サービスを自動起動するように設定します。
```bash
sudo systemctl daemon-reexec
sudo systemctl daemon-reload
sudo systemctl enable qwc-flask
sudo systemctl start qwc-flask
````

以上で、`Flask` アプリが直接にホスト内で動作するようになります。

## And so, what's next?

次こそは、本当に、`QWC2 Viewer` の編集機能を試します。ずっと先延ばししてきた案件ですが、もうこれぐらいしか残っていませんので、必ずやります。

- [qwc-services による Web GIS の構築 #5 編集機能](https://qiita.com/softark/items/ad9d590366d61a891e40)