Certain Cloud
> cd /blog
画像配信用のAPIをWorkers+R2で作ったのサムネイル

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

2025/08/12

背景

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

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

目指すもの

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

って感じです。

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

みたいに書くと

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

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

終わり

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

お し ま い