2025/08/12

画像配信用のAPIをWorkers+R2で作った

多分速いと思います

背景

Nuxtのブログに画像を貼るのがめんどいと思っていたので適当にAPIを作りました。

前回の記事でWasabi+Workersで作ったと書いたけど、
なんか遅かったのでフルCloudflareで作り直しました。

目指すもの

  • /<filename> で画像を返す
  • /upload で画像アップロード
  • /list で一覧ページ

って感じです。

example.md
![](https://imgs.samenoko.work/nekonekoneko.webp)

みたいに書くと

返ってきます。(セリナのASMRは買いましょう)

【ブルーアーカイブ】セリナ(クリスマス)ASMR~それは聖なる、健やかで真っすぐな~ [Yostar] | DLsite
ブルーアーカイブのセリナ(クリスマス)ASMRが登場!「DLsite 同人」は同人誌・同人ゲーム・同人ボイス・ASMRのダウンロードショップ。お気に入りの作品をすぐダウンロードできてすぐ楽しめる!毎日更新しているのであなたが探している作品にきっと出会えます。国内最大級の二次元総合ダウンロードショップ「DLsite」!
【ブルーアーカイブ】セリナ(クリスマス)ASMR~それは聖なる、健やかで真っすぐな~ [Yostar] | DLsite favicon https://www.dlsite.com/home/work/=/product_id/RJ01427890.html
【ブルーアーカイブ】セリナ(クリスマス)ASMR~それは聖なる、健やかで真っすぐな~ [Yostar] | DLsite

書いたコード

src/index.ts
import { env } from 'cloudflare:workers'
import { Hono } from 'hono'
import { basicAuth } from 'hono/basic-auth'
import { getExtension } from 'hono/utils/mime'

type Bindings = {
    BUCKET: R2Bucket
    USERNAME: string
    PASSWORD: string
}

const app = new Hono<{ Bindings: Bindings }>()

app.use('/upload', basicAuth({
    username: env.USERNAME,
    password: env.PASSWORD
}))

app.use('/list', basicAuth({
    username: env.USERNAME,
    password: env.PASSWORD
}))

app.get('/', (c) => {
    return c.json({
        message: "Hello World!",
    })
})

app.get('/list', async (c) => {
    const cache = caches.default
    const cacheKey = new Request(c.req.url, { method: 'GET' })
    const cachedResponse = await cache.match(cacheKey)
    
    if (cachedResponse) {
        return cachedResponse
    }

    const objects = await c.env.BUCKET.list()

    const imageList = objects.objects.map(obj => ({
        id: obj.key,
        size: obj.size,
        uploaded: obj.uploaded,
        contentType: obj.httpMetadata?.contentType || 'application/octet-stream' 
    }))
    
    const htmlContent = `
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>画像一覧</title>
    <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
    <script>
        function copyToClipboard(text, button) {
            navigator.clipboard.writeText(text).then(() => {
                const originalText = button.textContent;
                button.textContent = 'コピー完了!';
                button.classList.remove('bg-blue-500', 'hover:bg-blue-600');
                button.classList.add('bg-green-500');

                setTimeout(() => {
                    button.textContent = originalText;
                    button.classList.remove('bg-green-500');
                    button.classList.add('bg-blue-500', 'hover:bg-blue-600');
                }, 2000);
            }).catch(err => {
                console.error('コピーに失敗しました:', err);
                alert('コピーに失敗しました');
            });
        }
    </script>
</head>
<body class="bg-gray-50 min-h-screen">
    <div class="max-w-7xl mx-auto px-4 py-8">
        <h1 class="text-3xl font-bold text-center text-gray-800 mb-8">画像一覧</h1>
        
        <!-- アップロードフォーム -->
        <div class="bg-white rounded-lg shadow-md p-6 mb-8">
            <h3 class="text-lg font-semibold text-gray-700 mb-4">新しい画像をアップロード</h3>
            <form class="flex gap-4 items-center" action="/upload" method="post" enctype="multipart/form-data">
                <input type="file" name="image" accept="image/*" required 
                       class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent">
                <button type="submit" 
                        class="px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors duration-200">
                    アップロード
                </button>
            </form>
        </div>
        
        <!-- 画像一覧グリッド -->
        ${imageList.length === 0 ? 
            '<div class="text-center text-gray-500 italic text-lg">画像がありません</div>' : 
            `<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
                ${imageList.map(img => `
                    <div class="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-200">
                        <img src="/${img.id}" alt="${img.id}" 
                             class="w-full h-48 object-cover" loading="lazy">
                        <div class="p-4">
                            <div class="mb-3">
                                <div class="text-sm font-medium text-gray-900 mb-1 truncate" title="${img.id}">${img.id}</div>
                                <div class="text-xs text-gray-500 space-y-1">
                                    <div>サイズ: ${(img.size / 1024).toFixed(1)} KB</div>
                                    <div>アップロード: ${new Date(img.uploaded).toLocaleString('ja-JP')}</div>
                                    <div>タイプ: ${img.contentType}</div>
                                </div>
                            </div>
                            <div class="flex gap-2">
                                <button onclick="copyToClipboard('https://imgs.samenoko.work/${img.id}', this)" 
                                        class="flex-1 px-3 py-2 bg-blue-500 text-white text-sm rounded-md hover:bg-blue-600 transition-colors duration-200">
                                    URLをコピー
                                </button>
                                <a href="/${img.id}" target="_blank" 
                                   class="px-3 py-2 bg-gray-500 text-white text-sm rounded-md hover:bg-gray-600 transition-colors duration-200 text-center">
                                    開く
                                </a>
                            </div>
                        </div>
                    </div>
                `).join('')}
            </div>`
        }
    </div>
</body>
</html>`

    const response = c.html(htmlContent)

    if (!response.body) {
        return response
    }

    const [body1, body2] = response.body.tee()

    const originalResponse = new Response(body1, {
        ...response,
        headers: {
            ...response.headers,
            'Cache-Control': 'public, max-age=300'
        }
    })

    const responseToCache = new Response(body2, {
        ...response,
        headers: {
            ...response.headers,
            'Cache-Control': 'public, max-age=300'
        }
    })

    c.executionCtx.waitUntil(cache.put(cacheKey, responseToCache))
    
    return originalResponse
})

app.get('/:id', async (c) => {
    const object = await c.env.BUCKET.get(c.req.param('id'))
    if (!object) {
        return c.notFound()
    }

    const contentType = object.httpMetadata?.contentType ?? 'application/octet-stream'
    const data = await object.arrayBuffer()

    return c.body(data, 200, {
        'Cache-Control': 'public, max-age=31536000',
        'Content-Type': contentType
    })
})

app.put('/upload', async (c) => {
    const data = await c.req.parseBody<{ image: File }>()
    
    const body = data.image
    const type = data.image.type
    const extension = getExtension(type) ?? 'png'

    let key = (await crypto.randomUUID()) + '.' + extension

    await c.env.BUCKET.put(key, body, { httpMetadata: { contentType: type } })

    const cache = caches.default
    const listCacheKey = new Request(new URL('/list', c.req.url), { method: 'GET' })
    c.executionCtx.waitUntil(cache.delete(listCacheKey))

    return c.text(key, 200)
})

app.get('*', async (c, next) => {
    const cacheKey = c.req.url
    const cache = caches.default
    const cachedResponse = await cache.match(cacheKey)
    if (cachedResponse) {
        return cachedResponse
    }
    await next()
    if (!c.res.ok) {
        return
    }
    c.header('Cache-Control', 'public, max-age=31536000')
    const res = c.res.clone()
    c.executionCtx.waitUntil(cache.put(cacheKey, res))
})

export default app

フィーリングで読んでください。

ShareXの設定

タスクトレイアイコンを右クリック -> アップロード先 -> アップロード先を自分で設定

アップローダーを作成して以下のように設定する

  • 名前:好きなやつ
  • アップローダーの種類:画像アップローダ
  • メソッド:PUT
  • リクエスト先URL:WorkersのURL/upload
  • ヘッダー:
    • 名前:Authorization
    • 値:Basic <認証情報をbase64でエンコしたやつ>
  • Body:Form data
  • ファイル用のフォームのname:image
  • URL:WorkersのURL/{response}
  • サムネイルのURL:WorkersのURL/{response}

って感じにする。以下は設定例。

一覧画面

コードがダメなのでアップロードフォームは機能していない。
地味に便利です。

アップロード用アプリ

これはオンプレで動いてるやつ。
Pythonで作った。勝手にwebpに変換して上げてくれる。

終わり

以上です。結構いい感じに作れたので満足です。

お し ま い

© 2025 さめのこnoブログ