[HUGO+GAE+FastAPI] HUGOで作成したサイトをBasic認証によるアクセス制限付きで公開

# Hugoで作成したサイトをアクセス制限付きで公開したい

Hugoはとても簡単にサイトが作れて、かっこいいテーマもたくさんあって楽しいですね。
あるコミュニティ向けにちょっとした情報サイトを作ろうと思い、そうすると閲覧制限を付けたいので、どうすると良いのかなと考えました。

Hugoで作成したサイトを Google App Engine で公開します。
GETリクエストをFastAPIで処理して、特定のパスに対してはBasic認証を通さないとコンテンツを戻さないようにすることで、HUGOのコンテンツを、制限なし、制限付きに分けて公開するできるようにしました。

---

##

```:構成
.
├── app.py
├── app.yaml
├── mysite
│   ├── archetypes
│   ├── assets
│   ├── config.toml
│   ├── content
│   ├── data
│   ├── layouts
│   ├── public
│   ├── resources
│   ├── static
│   └── themes
└── requirements.txt
```


最終的なファイル構成はこのようになります。 `mysite`以下にHugoのサイトを置きます。

## Hugoでサイトを作る

Hugoでサイトを作ります。例として、以下のようにしています。

```
.
├── content
│   ├── _index.md
│   ├── docs
│   │   ├── caution.md
│   │   ├── doc.md
│   │   └── secret
│   │       └── secret.md
```

`docs/doc.md` は通常のページ。制限無しに公開するページです。
`docs/secret` 以下にあるページは制限付きのページにします。
`docs/caution.md` は制限付きページの認証に失敗した際に表示するページです。

```toml:config.toml
baseURL = "https://xxxxxxxxx.df.r.appspot.com/"
languageCode = "ja"
title = "おもしろサイト"
...
```

`config.toml` の baseURL を GAEのURLに変えておきます。



## GAE向けのファイルを用意する

```yaml:app.yaml
runtime: python39
entrypoint: uvicorn app:app --port $PORT
```
+ `app.yaml` 

シンプル。

---

```requirements.txt
fastapi
uvicorn
aiofiles
```

+ `requirements.txt`

`aiofiles` はFastAPIが `FileResponse` を返すのに必要みたいです。
webフレームワークはFastAPIでなくとも良いですが、FastAPIは起動が早いので気に入っています。

---

```python:app.py 
import secrets
from pathlib import Path
from fastapi import FastAPI, Depends, status, HTTPException
from fastapi.responses import FileResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials

app = FastAPI()
security = HTTPBasic()

@app.get("/docs/secret/{file_path:path}")
async def secret_path(
    file_path: str, credentials: HTTPBasicCredentials = Depends(security)
):

    correct_username = secrets.compare_digest(credentials.username, "user")
    correct_password = secrets.compare_digest(credentials.password, "pass")
    if not (correct_username and correct_password):
        response = FileResponse("mysite/public/docs/caution/index.html")
        response.status_code = status.HTTP_401_UNAUTHORIZED
        return response

    target = Path("mysite/public/docs/secret/{}".format(file_path))
    return read_file(target)


@app.get("/{file_path:path}")
async def public_path(file_path: str):
    target = Path("mysite/public/{}".format(file_path))
    return read_file(target)


def read_file(target: Path):
    if not target.exists():
        response = FileResponse("mysite/public/404.html")
        response.status_code = status.HTTP_404_NOT_FOUND
    elif target.is_dir():
        response = FileResponse(target.joinpath("index.html"))
    elif target.is_file():
        response = FileResponse(target)
    else:
        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)

    return response

```

### 説明

+ `app.py`


```python
@app.get("/docs/secret/{file_path:path}")
async def secret_path(
    file_path: str, credentials: HTTPBasicCredentials = Depends(security)
):
    correct_username = secrets.compare_digest(credentials.username, "user")
    correct_password = secrets.compare_digest(credentials.password, "pass")
    if not (correct_username and correct_password):
        response = FileResponse("mysite/public/docs/caution/index.html")
        response.status_code = status.HTTP_401_UNAUTHORIZED
        return response

    target = Path("mysite/public/docs/secret/{}".format(file_path))
    return read_file(target)
```

↑ まず制限付きページへのGETリクエストを受けるデコレータを、定義します。

Basic認証のログインダイアログを表示して、IDとPASSを受け取ります。
入力されたIDとPASSを検証して、一致していなければ、認証失敗を示すページを`FileResponse`で返します。
ステータスコードは認証失敗を示す`401`にしておきます。そうしておかないと、再度制限付きページにアクセスしたときにログインダイアログが表示されなくなります(一度入力したものが自動で使われてしまう？)。

ID/PASSが一致していれば、`mysite/public`以下のファイルパスへの置き換えをして`FileResponse`を返します。


```python
@app.get("/{file_path:path}")
async def public_path(file_path: str):
    target = Path("mysite/public/{}".format(file_path))
    return read_file(target)
```

↑ 制限のないページへのGETリクエストを受けるデコレータも定義します。

```python
def read_file(target: Path):
    if not target.exists():
        response = FileResponse("mysite/public/404.html")
        response.status_code = status.HTTP_404_NOT_FOUND
    elif target.is_dir():
        response = FileResponse(target.joinpath("index.html"))
    elif target.is_file():
        response = FileResponse(target)
    else:
        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)

    return response
```

↑ `mysite/public`以下のファイルパスをチェックして、もし存在していなければ`404.html`を返します。(テーマによってはパスが違うのかもしれません)
ディレクトリであれば、その直下の`index.html`を戻します。ファイルであれば、そのまま返します。
さいごよくわからないものはとりあえず`500 internal server error` にしておきました。


## こんなかんじ

![hoge.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123579/f5672552-63bb-adef-b9b5-d5377d6af9c8.gif)