2025/08/12
Nuxtのブログに画像を貼るのがめんどいと思っていたので適当にAPIを作りました。
前回の記事でWasabi+Workersで作ったと書いたけど、
なんか遅かったのでフルCloudflareで作り直しました。
って感じです。
みたいに書くと

返ってきます。(セリナのASMRは買いましょう)
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フィーリングで読んでください。
タスクトレイアイコンを右クリック -> アップロード先 -> アップロード先を自分で設定
アップローダーを作成して以下のように設定する
って感じにする。以下は設定例。


コードがダメなのでアップロードフォームは機能していない。
地味に便利です。
これはオンプレで動いてるやつ。
Pythonで作った。勝手にwebpに変換して上げてくれる。
以上です。結構いい感じに作れたので満足です。
お し ま い