🌥️

[Next.js 14] ウェブアプリからAPIでNextCloudとファイルのやりとりする全記録

2024/01/25に公開

前提

レンタルサーバー上でNextCloudの環境を構築し、APIを通してNextCloud上のファイルをやりとりすることで、ウェブアプリケーションのストレージとして使うのが目的

NextCloudとは、Web Installerでセットアップ方法は前回の記事からどうぞ
https://zenn.dev/shomtsm/articles/cb28b854b0b0fe

本記事は、こちらのNextCloud公式ドキュメントにあるファイル・フォルダをやりとりするAPIを叩けるようにすることがゴールです
https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/basic.html#listing-folders-rfc4918

NextCloudのエンドポイント

直接ターミナル上で叩く分は問題ない

たとえば

  • フォルダのファイル一覧
curl -X PROPFIND -H "Depth: 1" -u user:pass https://nextcloud.sample.com/remote.php/dav/files/user

user:passはNextcloudのユーザー名とパスワード、nextcloud.sample.comはNextcloudサーバーのアドレス

  • フォルダの作成(現在の日付の名前で)
curl -u user:pass -X MKCOL "https://nextcloud.example.com/nextcloud/remote.php/dav/files/user/$(date '+%d-%b-%Y')"
  • ファイルのアップロード
curl -u user:pass -T error.log "https://nextcloud.example.com/nextcloud/remote.php/dav/files/user/$(date '+%d-%b-%Y')/hogehoge.png"
  • ファイルの移動
curl -u user:pass -X MOVE --header 'Destination: https://nextcloud.example.com/nextcloud/remote.php/dav/files/user/target.jpg' https://nextcloud.example.com/nextcloud/remote.php/dav/files/user/source.jpg

source.jpg は移動するファイル、target.jpg は移動先のファイル名

  • ファイルのプロパティ取得
curl -X PROPFIND -H "Depth: 1" -u user:pass https://nextcloud.example.com/nextcloud/remote.php/dav/files/user/

CORSの壁

next.jsでAPIたたいてみる

image-fetcher.tsx
// ❌ これはうごかない
useEffect(() => {
    const fetchImageData = async () => {
      try {
        const response = await axios({
          method: 'GET',
          url: `${nextcloudUrl}/remote.php/dav/files/${username}/hogehoge.jpg`,
          auth: { username, password },
          headers: {
            'Content-Type': 'text/xml',
          },
        });
        const fileUrl = response.data; 
        setImageUrl(fileUrl);
      } catch (error) {
        console.error('Error fetching image from Nextcloud', error);
      }
    };

レスポンスフォーマットは一旦おいておいて、まずCORSにひっかかり叩けない。
.htaccessに指定ドメイン許可するように設定してみる

.htaccess
# ❌ これは効かない
<IfModule mod_headers.c>
  Header set Access-Control-Allow-Origin "http://localhost:3000"
</IfModule>

→ NextCloudのCORSにはひっかかるまま
あれこれ調べて試行錯誤してたどり着いたのはこちらのリポジトリー
https://github.com/thanek/nextcloud-react-app
READMEに従って

CORSを許可するためにNextCloudのPHPプログラムに修正を入れる

webdavファイルAPIでCORSリクエストを許可する

/remote.php

下記のブロックを探す

if (\OCP\Util::needUpgrade()) {
    // since the behavior of apps or remotes are unpredictable during
    // an upgrade, return a 503 directly
    throw new RemoteException('Service unavailable', 503);
}

このブロックの下に下記プログラム追加

/remote.php
// Allow from any origin
if (isset($_SERVER['HTTP_ORIGIN'])) {
    // Decide if the origin in $_SERVER['HTTP_ORIGIN'] is one
    // you want to allow, and if so:
    header("Access-Control-Allow-Origin: {$_SERVER['HTTP_ORIGIN']}");
    header('Access-Control-Allow-Credentials: true');
    header('Access-Control-Max-Age: 86400');    // cache for 1 day
}

// Access-Control headers are received during OPTIONS requests
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
    if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD']))
        // may also be using PATCH, HEAD etc
        header("Access-Control-Allow-Methods: *");

    if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']))
        header("Access-Control-Allow-Headers: {$_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']}");

    exit(0);
}

サムネイルもCORSリクエストを許可するする

/apps/files/lib/Controller/ApiController.php

\OCA\Files\Controller\ApiControllerクラス内にpublic function getThumbnail($x, $y, $file)というメソッドがあり、そのメソッドの/** */で囲まれたコメントのようなところに@CORSを追加

/apps/files/lib/Controller/ApiController.php
/**
* Gets a thumbnail of the specified file
* 大量コメント省略、下記追加
* @CORS
*/
public function getThumbnail($x, $y, $file) 

apps/files/appinfo/routes.php

このファイルにroutesという配列があり、その配列に下記要素を追加

[
    'name' => 'API#preflightedCors',
    'url' => '/api/v1/{path}',
    'verb' => 'OPTIONS',
    'requirements' => ['path' => '.*']
],

これでAPIは叩けた!

API経由のファイル・フォルダやりとり

nextcloudUrl(例えばhttps://nextcloud.sample.com)、username、passwordは環境変数にいれている状態からスタート

const nextcloudUrl = process.env.NEXTCLOUD_URL || ''
const username = process.env.NEXTCLOUD_USERNAME || ''
const password = process.env.NEXTCLOUD_PASSWORD || ''

画像取得(バイナリ)

  • エンドポイント
    そのままGETするのはバイナリデータで、responseType: 'arraybuffer'を書くのがポイント
src/app/api/nc-folder/route.ts
export async function GET(request: NextRequest) {
  const filepath = request.nextUrl.searchParams.get('filepath')
  if (!filepath)
    return new NextResponse(null, {
      status: 400,
      statusText: 'Filename is required',
    })

  try {
    const response = await axios({
      method: 'GET',
      url: `${nextcloudUrl}/remote.php/dav/files/${username}${filepath}`,
      auth: { username, password },
      responseType: 'arraybuffer',
      headers: { Accept: 'image/*' },
    })
    const contentType = response.headers['content-type']
    return new NextResponse(response.data, {
      status: 200,
      statusText: 'OK',
      headers: { 'Content-Type': contentType },
    })
  } catch (error) {
    return new NextResponse(null, { status: 500 })
  }
}
  • フロントで受け取ったバイナリをblobに変換して表示する
const fetchByPath = async () => {
    try {
      const response = await axios.get('/api/nc-image', {
        responseType: 'blob',
        headers: { 'X-CSRF-Token': csrfToken },
        params: { filepath: `/${folderName}/${filename}` },
      })
      const imageBlob = response.data
      const blobUrl = URL.createObjectURL(imageBlob)
      setImageUrl(blobUrl)
    } catch (error) {
      console.error('Error fetching image:', error)
    }
  }
{imageUrl && <img src={imageUrl} alt='Nextcloud Image' />}

ストレージとして使うのが主に画像なのでとりあえず画像バージョン

ファイルアップロード

  • エンドポイント
src/app/api/nc-image/route.ts
export async function PUT(request: NextRequest) {
  const filepath = request.nextUrl.searchParams.get('filepath')
  const fileData = await request.blob()

  if (!filepath || fileData.size === 0)
    return new NextResponse(null, {
      status: 400,
      statusText: 'Filepath and file data are required',
    })

  try {
    const response = await axios({
      method: 'PUT',
      url: `${nextcloudUrl}/remote.php/dav/files/${username}${filepath}`,
      data: fileData,
      auth: { username, password },
      headers: { 'Content-Type': 'application/octet-stream' },
    })

    if (response.status !== 201 && response.status !== 204) {
      throw new Error(`Error uploading file: ${response.statusText}`)
    }

    return new NextResponse(null, {
      status: response.status,
      statusText: 'File uploaded successfully',
    })
  } catch (error) {
    console.error('Error uploading file to Nextcloud', error)
    return new NextResponse(null, { status: 500 })
  }
}
  • フロント
  const handleUpload: React.ChangeEventHandler<HTMLInputElement> = async (
    event
  ) => {
    if (!event.target.files || event.target.files.length === 0)
      throw new Error('You must select an image to handleUpload.')

    const file = event.target.files[0]
    const fileExt = file.name.split('.').pop()
    const newPath = `/${folderName}/${Math.random()}.${fileExt}`
    setNewPath(newPath)
    try {
      const response = await axios.put('/api/nc-image', file, {
        headers: { 'X-CSRF-Token': csrfToken },
        params: { filepath: newPath },
      })
      console.log('File uploaded successfully', response)
    } catch (error) {
      console.error('Error fetching image:', error)
    }
  }

ファイル削除

  • エンドポイント
src/app/api/nc-image/route.ts
export async function DELETE(request: NextRequest) {
  const filepath = request.nextUrl.searchParams.get('filepath')

  if (!filepath)
    return new NextResponse(null, {
      status: 400,
      statusText: 'Filepath is required',
    })

  try {
    const response = await axios({
      method: 'DELETE',
      url: `${nextcloudUrl}/remote.php/dav/files/${username}${filepath}`,
      auth: { username, password },
    })

    if (response.status !== 204)
      throw new Error(`Error deleting file: ${response.statusText}`)

    return new NextResponse(null, {
      status: 204,
      statusText: 'File deleted successfully',
    })
  } catch (error) {
    console.error('Error deleting file from Nextcloud', error)
    return new NextResponse(null, { status: 500 })
  }
}
  • フロント
  const deleteByPath = async () => {
    try {
      const response = await axios.delete('/api/nc-image', {
        headers: { 'X-CSRF-Token': csrfToken },
        params: { filepath: `/${folderName}/${filename}` },
      })
      console.log('File deleted successfully', response)
    } catch (error) {
      console.error('Error fetching image:', error)
    }
  }

フォルダ作成

  • エンドポイント
src/app/api/nc-folder/route.ts
export async function POST(request: NextRequest) {
  const folderName = request.nextUrl.searchParams.get('folderName')
  if (!folderName)
    return new NextResponse(null, {
      status: 400,
      statusText: 'Folder name is required',
    })

  try {
    const response = await axios({
      method: 'MKCOL',
      url: `${nextcloudUrl}/remote.php/dav/files/${username}/${folderName}`,
      auth: {
        username: username,
        password: password,
      },
    })

    if (response.status !== 201)
      throw new Error(`Error creating folder: ${response.statusText}`)
    
    return new NextResponse(null, {
      status: 201,
      statusText: 'Folder created successfully',
    })
  } catch (error) {
    console.error('Error creating folder in Nextcloud', error)
    return new NextResponse(null, { status: 500 })
  }
}
  • フロント
  const createFolder = async () => {
    try {
      const response = await axios.post('/api/nc-folder', null, {
        headers: { 'X-CSRF-Token': csrfToken },
        params: { folderName },
      })
      console.log('Folder created successfully', response)
    } catch (error) {
      console.error('Error fetching image:', error)
    }
  }

フォルダのファイル一覧(xml→js配列へ変換)

  • エンドポイント
export async function GET(request: NextRequest) {
  const folderName = request.nextUrl.searchParams.get('folderName')
  if (!folderName)
    return new NextResponse(null, {
      status: 400,
      statusText: 'Filename is required',
    })

  try {
    const response = await axios({
      method: 'PROPFIND',
      url: `${nextcloudUrl}/remote.php/dav/files/${username}/${folderName}`,
      auth: { username, password },
      headers: { 'Content-Type': 'text/xml' },
    })

    return new NextResponse(response.data, {
      status: 200,
      statusText: 'OK',
    })
  } catch (error) {
    console.error('Error fetching image from Nextcloud', error)
    return new NextResponse(null, { status: 500 })
  }
}
  • フロントでxmlをnew DOMParser()使って変換
const fetchFolder = async () => {
    try {
      const response = await axios.get('/api/nc-folder', {
        headers: {
          'X-CSRF-Token': csrfToken,
        },
        params: { folderName },
      })
      const resFolder = response.data
      const parser = new DOMParser()
      const xmlDoc = parser.parseFromString(resFolder, 'text/xml')
      const files = []

      // <d:response> タグを取得する
      const responses = xmlDoc.getElementsByTagName('d:response')
      // 各 <d:response> タグに対して処理を行う
      for (let i = 0; i < responses.length; i++) {
        const href = responses[i].getElementsByTagName('d:href')[0].textContent
        // フォルダ自体のパスは除外する
        if (href !== `/remote.php/dav/files/shomtsm/${folderName}/` && href)
          files.push(href.split('/').pop())
      }
      setFiles(files)
    } catch (error) {
      console.error('Error fetching image:', error)
    }
  }

というわけで小ちゃなアプリケーションには十二分ストレージとして足りる状態になった!
vercelで本番までデプロイしても問題なくファイルのやりとりできる

補足: Webアプリ用のNextCloudアカウント

管理者アカウントの下に、webアプリ専用のAPI叩くためのアカウントを作るのは、
個人 → セキュリティ → デバイスとセッション → 新しいアプリパスワード作成
で行える

補足: 特定のドメインのみCORSリクエストを許可

許可するドメインを配列で定義し、$_SERVER['HTTP_ORIGIN'] の値をチェックして、それが許可リストにあるかどうかを確認してから許可・拒否する

/apps/files/lib/Controller/ApiController.php
$allowed_domains = ['http://localhost:3000', 'https://sample.com'];

// Allow from specific origins
if (isset($_SERVER['HTTP_ORIGIN'])) {
    if (in_array($_SERVER['HTTP_ORIGIN'], $allowed_domains)) {
        header("Access-Control-Allow-Origin: {$_SERVER['HTTP_ORIGIN']}");
        header('Access-Control-Allow-Credentials: true');
        header('Access-Control-Max-Age: 86400'); // cache for 1 day
    } else {
        header('HTTP/1.1 403 Forbidden');
        exit;
    }
}

// Access-Control headers are received during OPTIONS requests
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
    if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'])) {
        // may also be using PATCH, HEAD etc
        header("Access-Control-Allow-Methods: *");
    }

    if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'])) {
        header("Access-Control-Allow-Headers: {$_SERVER['HTTP_ACCESS_CONTROL_REQUEST_HEADERS']}");
    }

    exit(0);
}

Webアプリのストレージ代わりとして

たぶん、もう少しちゃんとやらんといかない
nextcloudにアクセスするためのusernameとは別に、nextcloud_username配下にアプリケーションのバケット(フォルダ)を作って、バケットの中に更にアプリユーザーのusernameでフォルダを作り、画像は更にその中で読み書きするのを考えると、アプリユーザーのフォルダごとに権限管理が必要になるかも。。。
また考える

Discussion