Base64画像をLIFF経由でLINEトークに送信する実装パターン
2025-12-02
azblob://2025/12/08/eyecatch/liff-base64-image-upload-000.png

Base64画像をLIFF経由でLINEトークに送信する実装パターン

はじめに

こんにちは。久々の投稿です。
LINE LIFF(LINE Front-end Framework)アプリで、ユーザーが撮影した画像をLINEトークに送信する機能を実装する機会がありました。本記事では、Base64画像をAPI経由でS3にアップロードし、そのURLをliff.sendMessages()でトークに表示させるパターンについて解説します。なぜなら僕がつまったからです。

全体のアーキテクチャ

┌─────────────────────────────────────────────────────────────────┐
│ LIFF App (React) │
├─────────────────────────────────────────────────────────────────┤
│ 1. <input type="file" capture="environment" /> │
│ ↓ │
│ 2. FileReader.readAsDataURL(file) │
│ ↓ │
│ 3. Base64文字列 (data:image/jpeg;base64,/9j/4AAQ...) │
│ ↓ │
│ 4. axios.post('/upload-insurance-image', { imageData }) │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ NestJS API Server │
├─────────────────────────────────────────────────────────────────┤
│ 5. Base64デコード → Buffer │
│ ↓ │
│ 6. S3にアップロード │
│ ↓ │
│ 7. S3のURLを返却 │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ LIFF App (React) │
├─────────────────────────────────────────────────────────────────┤
│ 8. liff.sendMessages([{ type: 'image', ... }]) │
│ ↓ │
│ 9. LINEトークに画像が表示される │
└─────────────────────────────────────────────────────────────────┘

実装の詳細

Step 1: カメラ起動と画像取得(フロントエンド)

// ファイル入力のref
const fileInputRef = useRef<HTMLInputElement>(null);
const [previewImage, setPreviewImage] = useState<string | null>(null);

// カメラ起動
const handlePaperInsurance = () => {
  fileInputRef.current?.click();
};

// ファイル選択後の処理
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  const file = e.target.files?.[0];
  if (!file) return;

  // FileReaderでBase64に変換
  const reader = new FileReader();
  reader.onload = (event) => {
    setPreviewImage(event.target?.result as string);
  };
  reader.readAsDataURL(file);

  // 同じファイルを再選択できるようにリセット
  e.target.value = '';
};

// JSX
<input
  type="file"
  accept="image/*"
  capture="environment" // リアカメラを使用
  ref={fileInputRef}
  onChange={handleFileChange}
  style={{ display: 'none' }}
/>

ポイント

  • capture="environment"でモバイル端末のリアカメラ(背面カメラ)を起動
  • FileReader.readAsDataURL() でBase64形式のData URLに変換
  • 結果は data:image/jpeg;base64,/9j/4AAQSkZJRg... のような形式になる

Step 2: 画像アップロードとLINE送信(フロントエンド)

const handleUpload = async () => {
  if (!previewImage) return;
  setIsUploading(true);

  try {
    // 1. APIにBase64画像を送信してS3 URLを取得
    const uploadResult = await apiWrapper.LineService
      .linePatientInfoControllerUploadInsuranceImage({
        imageData: previewImage, // Base64文字列をそのまま送信
        lineUserId: currentUser.line_user_id,
        idToken: currentUser.id_token,
        channelId: channelId,
      });

    if (!uploadResult.success || !uploadResult.imageUrl) {
      throw new Error('画像のアップロードに失敗しました');
    }

    // 2. liff.sendMessages()でLINEトークに画像を送信
    await liff.sendMessages([
      {
        type: 'image',
        originalContentUrl: uploadResult.imageUrl, // S3のURL
        previewImageUrl: uploadResult.imageUrl, // サムネイルも同じURL
      },
    ]);

    // 3. 送信完了後、LIFFアプリを閉じる
    setTimeout(() => {
      liff.closeWindow();
    }, 1500);
  } catch (error) {
    console.error('アップロードエラー:', error);
  } finally {
    setIsUploading(false);
  }
};

ポイント

  • liff.sendMessages() はユーザー自身のメッセージとして送信される
  • originalContentUrl と previewImageUrl にはHTTPS URLが必要
  • Base64を直接送れないため、一度S3等にアップロードしてURLを取得する必要がある

Step 3: Base64デコードとS3アップロード(バックエンド)

DTO定義

export class UploadImageDTO {
	@ApiProperty({
	description: 'Base64エンコードされた画像データ(data:image/jpeg;base64,...形式)',
	example: 'data:image/jpeg;base64,/9j/4AAQSkZJRg...',
	})
	@IsNotEmpty()
	@IsString()
	imageData: string;
}

export class UploadImageResponseDTO {
	@ApiProperty({ description: 'アップロードされた画像のURL' })
	imageUrl: string;
	@ApiProperty({ description: '処理が成功したかどうか' })
	success: boolean;
}

Service実装

async uploadImage(
  uploadData: UploadInsuranceImageDTO,
): Promise<UploadInsuranceImageResponseDTO> {
  // 1. Base64 Data URLをパース
  const matches = uploadData.imageData.match(
    /^data:([a-zA-Z0-9]+\/[a-zA-Z0-9-.+]+);base64,(.+)$/,
  );

  if (!matches || matches.length !== 3) {
    throw new AppError('無効な画像データ形式です', 'INVALID_PARAMETERS');
  }

  const mimeType = matches[1]; // 'image/jpeg'
  const base64Data = matches[2]; // '/9j/4AAQSkZJRg...'

  // 2. Base64をBufferにデコード
  const fileBuffer = Buffer.from(base64Data, 'base64');

  // 3. MIMEタイプから拡張子を決定
  const extensionMap: Record<string, string> = {
    'image/jpeg': 'jpg',
    'image/jpg': 'jpg',
    'image/png': 'png',
    'image/gif': 'gif',
    'image/webp': 'webp',
  };
  const extension = extensionMap[mimeType] || 'jpg';

  // 4. ユニークなファイル名を生成
  const timestamp = new Date().getTime();
  const randomStr = Math.random().toString(36).substring(2, 10);
  // ここの名前しっかり考えた方がいい→インデックスが早くなるとか何とか
  const fileName = `insurance-cards/insurance_${timestamp}_${randomStr}.${extension}`;

  // 5. S3にアップロード
  const s3Client = new S3Client({
    region: awsConstants.region,
    credentials: {
      accessKeyId: awsConstants.accessKey,
      secretAccessKey: awsConstants.secretKey,
    },
  });

  const command = new PutObjectCommand({
    Bucket: bucket,
    Key: fileName,
    Body: fileBuffer,
    ContentType: mimeType,
  });

  await s3Client.send(command);

  // 6. S3のURLを返却
  const imageUrl = `https://${bucket}.s3.amazonaws.com/${fileName}`;

  return {
    success: true,
    imageUrl,
  };
}

ポイント

  • 正規表現でData URLからMIMEタイプとBase64データを分離
  • Buffer.from(base64Data, 'base64') でバイナリに変換
  • ファイル名はタイムスタンプ+ランダム文字列で衝突を回避

なぜBase64 → S3 → liff.sendMessages() なのか? 

 liff.sendMessages()の制約 

liff.sendMessages()で画像を送信する場合、以下の制約があります:
| 項目 | 制約 |
|------|------|
| URL形式 | **HTTPS必須** |
| Base64直接送信 | **不可** |
| ローカルファイル | **不可** |
| URL有効性 | **公開アクセス可能なURL** |
そのため、以下のフローが必要になります:
Base64 → サーバーに送信 → S3等にアップロード → 公開URLを取得 → liff.sendMessages()

なぜWeb Share APIを使わないのか?

| 観点 | Web Share API | liff.sendMessages() |
|------|---------------|---------------------|
| LINE特化 | × | ○(LINE専用) |
| 送信先 | ユーザーが選択 | 現在のトーク |
| ファイル形式 | Blob/File | URL |
| LIFFとの親和性 | 低い | 高い |
LIFFアプリでは liff.sendMessages() を使うことで、ユーザーが開いているトークルームに直接メッセージを送信できます。
 

注意点とTips

 1. Base64のサイズ問題

Base64エンコードするとデータサイズが約33%増加します。大きな画像を扱う場合は、フロントエンドで圧縮してから送信することを検討してください。

// 画像圧縮の例(canvas使用)
const compressImage = (file: File, maxWidth: number = 1024): Promise<string> => {
  return new Promise((resolve) => {
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    const img = new Image();

    img.onload = () => {
      const ratio = maxWidth / img.width;
      canvas.width = maxWidth;
      canvas.height = img.height * ratio;
      ctx?.drawImage(img, 0, 0, canvas.width, canvas.height);
      resolve(canvas.toDataURL('image/jpeg', 0.8));
    };

    img.src = URL.createObjectURL(file);
  });
};

 2. S3バケットの公開設定

liff.sendMessages() で送信する画像URLは公開アクセス可能である必要があります。S3バケットのポリシーやCloudFrontの設定を確認してください。 

3. エラーハンドリング

try {
  await liff.sendMessages([...]);
} catch (error) {
  if (error.code === 'INVALID_ARGUMENT') {
    // URLが無効
  } else if (error.code === 'FORBIDDEN') {
    // 権限エラー(LIFFアプリ外で実行など)
  }
}

4. LIFFアプリ外での動作

liff.sendMessages() はLIFFアプリ内(LINE内ブラウザ)でのみ動作します。外部ブラウザでは使用できません。

if (!liff.isInClient()) {
	// 外部ブラウザの場合の代替処理
	alert('この機能はLINEアプリ内でのみ利用できます');
	return;
} 

まとめ

LIFFアプリでBase64画像をLINEトークに送信するパターンをまとめました。 

1. フロント: FileReader.readAsDataURL() でBase64に変換
2. API: Base64をデコードしてS3にアップロード、URLを返却
3. フロント: liff.sendMessages() でS3のURLを送信

追記

この方法でも実装できました。バックエンドでの処理がなければこれでもいいのかもしれないです。

カメラ起動後に liff.closeWindow() を呼ぶことで、元のLIFFウィンドウを閉じています。ユーザーが撮影後にLINEのトーク画面に戻り、撮影した画像をそのまま送信できるフローを実現しています。

  import liff from '@line/liff';

  const handleCameraModule = () => {
    try {
      liff.openWindow({
        url: 'https://line.me/R/nv/camera/',
        external: true,
      });
      liff.closeWindow();
    } catch (error) {
      console.error('LINEカメラを開く際にエラーが発生しました:', error);
    }
  };

参考文献