使用cloudflare搭建个人图床

上次分享了一篇使用 cloudflare 作为个人图床的方案,但是比较粗糙,仅仅是直接使用 cloudflare 的 R2 的控制台上传图片,自己拼接图片地址,属于能用,但并不好用的状态。

这次我们更产品一点,做一个可视的个人图床应用,包括图片上传、删除、查看、搜索。如果需要将其真正用于生产,还需要一个域名,因为 cloudflare 的免费 dev 域名是被墙掉的。

最终成品be like:

图床应用-列表

图床应用-图片列表

<img src="https://aipaint.lihuanyu.com/2023-12-04/图床应用-上传图片.jpg" alt="图床应用-列表" style="box-shadow: 5px 5px 10px #888;">
<p style="margin-top: -16px; color: #999; font-size: 14px">图床应用-上传图片</p>

使用到的平台包括 cloudflare 和 GitHub。GitHub 用于存储代码。cloudflare 要使用其如下功能:

  • Worker 作为计算单元执行存储、查询逻辑
  • Page 服务作为前端网页托管平台
  • R2 作为图片存储
  • D1 作为数据库

对于个人用户,这些服务几乎都是免费的(每日免费额度对个人用户几乎用不完)。

方案架构

cloudflare个人图床架构

注册并创建服务

这并不是一篇面向小白的文章,所以这里假定你已经熟练掌握 GitHub 的使用了。但如果没有使用过 cloudflare ,先去其官网简单创建一个账号。然后开通以下服务:

创建一个 R2 bucket,随便取个名字,比如 image-storage-demo 。创建一个 D1 数据库取名比如 image-storage-record ,创建一张表叫 images ,字段如下:

接下来创建 worker,创建 worker 时需要稍微注意下,要创建两个,分别用于做页面渲染(前端)和业务逻辑(后端)。虽然worker可以同时做前后端,但为了长期的可维护性,这里我们还是区分下前端和服务端。

worker 取个名字比如 image-storage-worker

创建前端项目

在 GitHub 上创建一个仓库,clone到本地,初始化一个前端项目,前端框架这里为了尝鲜,选择了 SolidJS 。关于这个框架的更多信息,可自行查询相关文档。也可以选择自己喜欢的前端框架比如 Vue、React 等,都一样。

npm create vite@latest my-app -- --template solid-ts 即可创建项目。

为了有一些基础的样式,可以使用 tailwindCSS 作为样式库,直接去 tailwindCSS 的官网找 SolidJS 框架的安装指南照着操作即可,嫌麻烦也可以省略,又不是不能用.jpg。

部署前端页面

简单编写一个图片上传组件:

import { createSignal } from 'solid-js'

function ImageUpload () {
  const [selectedFile, setSelectedFile] = createSignal(null)

  const handleFileChange = (event) => {
    const file = event.target.files[0]
    setSelectedFile(file)
  }

  const handleUpload = async () => {
    if (!selectedFile()) {
      alert('Please select a file first')
      return
    }

    const formData = new FormData()
    formData.append('file', selectedFile())

    try {
      const response = await fetch('https://{上传接口地址}/', {
        method: 'POST',
        body: formData,
      })

      if (response.ok) {
        const result = await response.json()
        console.log('result', result)
        window.alert('上传成功')
      } else {
        console.error('Upload failed. HTTP status:', response.status)
      }
    } catch (error) {
      console.error('Error during upload:', error)
    }
  }

  return (
    <div class="max-w-screen-md mx-auto p-4">
      <h1 class="text-2xl font-bold mb-4">Image Upload</h1>
      <input
        type="file"
        accept="image/*"
        onChange={handleFileChange}
        class="mb-4"
      />

      <button onClick={handleUpload} class="bg-blue-500 text-white py-2 px-4 rounded">
        Upload Image
      </button>
    </div>
  )
}

export default ImageUpload

注意这里上传图片的接口还没写,等下写完 worker 逻辑再加。引入到 app.ts 里,使用它将其展示出来,即可提交代码到 Github。

这时再去 cloudflare worker page 功能下,选关联已有前端项目,关联这个 solidJS 项目,然后就会自动给分配一个域名并部署,如果你有自己的域名,也可以配置自定义域名,即可无需科学访问。

编写服务端逻辑

需要安装 cloudflare 的命令行工具,叫 wrangler ,具体参考其官方文档。这里简单写下流程:先安装依赖 npm install -g wrangler,再登录 wrangler login ,接着把项目搞到本地来写 wrangler init --from-dash image-storage-worker (注意和前面创建 worker 时名字一致),可以额外建立一个 git 仓库存储服务端逻辑代码。

项目到本地后首先编辑 wrangler.toml,加上你的 R2 bucket 和 D1:

[[d1_databases]]
binding = "DATABASE" # JS逻辑里使用时的变量名
name = "image-storage-record" # 和前面创建的 D1 数据库名一致

[[r2_buckets]]
binding = 'imageOSS' # JS逻辑里使用时的变量名
bucket_name = 'image-storage-demo' # 和前面创建的 R2 bucket 名一致

编辑src/index.ts,修改如下:

const corsHeaders = {
  'Access-Control-Allow-Headers': '*', // What headers are allowed. * is wildcard. Instead of using '*', you can specify a list of specific headers that are allowed, such as: Access-Control-Allow-Headers: X-Requested-With, Content-Type, Accept, Authorization.
  'Access-Control-Allow-Methods': '*', // Allowed methods. Others could be GET, PUT, DELETE etc.
  'Access-Control-Allow-Origin': '*', // This is URLs that are allowed to access the server. * is the wildcard character meaning any URL can.
};

export default {
  async fetch(request, env) {
    const requestURL = new URL(request.url);
    // 查看所有图片
    if (request.method === 'GET' && requestURL.pathname === '/query') {
      return await handleQueryImage(request, env);
    }

    // 处理图片上传
    if (request.method === 'POST' && requestURL.pathname === '/upload') {
      return await handleImageUpload(request, env);
    }

    return new Response('Invalid request', { status: 400 });
  },
};

const handleImageUpload = async (request, env) => {
  const { DATABASE, imageOSS } = env;

  const formData = await request.formData();
  const file = formData.get('file');

  if (file) {
    const path = `${file.name}`;
    const imageFullPath = `https://{这里换成你的R2域名}/${path}`;
    await imageOSS.put(path, file);
    const createdAt = `${+new Date()}`;

    try {
      const { success } = await DATABASE.prepare(
        `insert into images (imageName, imageUrl, createdAt) values (?, ?, ?)`,
      )
        .bind(path, imageFullPath, createdAt)
        .run();

      return new Response(JSON.stringify({ url: imageFullPath, success }), {
        headers: {
          ...corsHeaders,
        },
      });
    } catch (e) {
      return new Response(
        JSON.stringify({
          success: false,
          error: JSON.stringify(e),
        }),
        {
          headers: {
            ...corsHeaders,
          },
        },
      );
    }
  }
};

const handleQueryImage = async (request, env) => {
  const { DATABASE } = env;
  const requestURL = new URL(request.url);
  const pageNum = Number(requestURL.searchParams.get('pageNum')) || 1;
  const pageSize = Number(requestURL.searchParams.get('pageSize')) || 10;
  const offset = (pageNum - 1) * pageSize;
  const sql = `select * from images order by id DESC LIMIT ${pageSize} OFFSET ${offset}`;

  const rows = await DATABASE.batch([
    DATABASE.prepare(sql),
    DATABASE.prepare(`SELECT COUNT(*) AS total FROM images order by id DESC`),
  ]);
  return new Response(
    JSON.stringify({
      results: rows[0].results,
      total: (rows[1]?.results && rows[1]?.results[0]?.total) || 0,
      success: rows[0].success && rows[1].success,
    }),
    {
      headers: {
        ...corsHeaders,
      },
    },
  );
};

接下来只需要简单的 wrangler deploy 即可部署完成。然后去看下 cloudflare 给你分配的域名,或者是绑定一个自己的自定义域名上去。拿着这个域名我们就可以去连接前后端了。

连接前后端

刚刚前端上传页面留了一个上传地址,填入 https://{刚刚拿到的域名}/upload 即对接好了图片上传,worker收到请求会一边把图片存到 R2 里一边把图片信息记录到 D1 里。 可以尝试下看是否返回了成功。

成功后我们去 cloudflare 的控制台去 D1 里看一眼数据是否入库,再去 R2 里看一眼图片文件是否存在,一切正常后我们再编写一个列表展示页。

import { createResource, createSignal, For } from 'solid-js'
import dayjs from 'dayjs'
import copy from 'clipboard-copy'

const fetchPics = async ({ pageNum, pageSize }) =>
  (await fetch(`https://{你的worker地址}/query?pageSize=${pageSize}&pageNum=${pageNum}`)).json()

function ImageHome () {
  const pageSize = 20

  const [pageNo, setPageNo] = createSignal(1)

  const fetchOption = () => {
    return {
      pageNum: pageNo(),
      pageSize,
    }
  }

  const [picList] = createResource(fetchOption, fetchPics)

  const copyToClipboard = async (text) => {
    try {
      // 调用 clipboard-copy 库的方法将内容复制到剪贴板
      await copy(text)
    } catch (error) {
      console.error('Error copying to clipboard:', error)
    }
  }

  return (
    <div>
      <div class="flex p-4 lg:p-10 justify-between lg:pb-0 pb-0 items-center">
        <h1 class="p-4 text-2xl">图片列表</h1>

        <p>当前第 {pageNo()} 页,共 {picList.loading ? 'loading' : picList().total} 条数据</p>
      </div>

      <div class="p-4 lg:p-10">
        <span>{picList.loading && 'Loading...'}</span>

        {!picList.loading && (
          <div class="grid 3xl:grid-cols-4 2xl:grid-cols-3 lg:grid-cols-2 sm:grid-cols-1 gap-6 2xl:gap-8 pb-10">
            <For each={picList().results}>{(picItem) =>
              <div
                class="flex flex-col overflow-hidden items-center justify-center bg-slate-200 rounded-xl p-0 flex-wrap md:flex-nowrap">
                <div class="flex flex-shrink-0 items-center w-auto h-96 rounded-none mx-auto p-2 justify-center">
                  <img class="h-full object-contain" src={picItem.imageUrl}/>
                </div>

                {/*信息区*/}
                <div class="w-full pt-2 pl-5">
                  <div>图片名: {picItem.imageName}</div>
                  <div>创建时间: {dayjs(+picItem.createdAt).format('YYYY/MM/DD HH:mm:ss')}</div>
                  <div>完整地址: {picItem.imageUrl}</div>
                </div>

                {/*操作区*/}
                <div class="w-full flex p-3 justify-between">
                  <div
                    class="m-2 w-full text-center bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded cursor-pointer"
                    onClick={() => copyToClipboard(picItem.imageUrl)}>复制
                  </div>
                </div>
              </div>
            }</For>
          </div>
        )}
      </div>

      <div class="fixed bottom-0 left-0 p-4 w-full flex flex-1 mt-8 justify-between bg-white shadow-inner">
        {pageNo() > 1 ? (
          <p
            class="w-22 relative inline-flex justify-center items-center rounded-md border border-gray-300 text-white px-4 py-2 text-sm font-medium bg-blue-500"
            onClick={() => (setPageNo((p) => p - 1))}
          >上一页</p>
        ) : <p class="w-20"/>}

        <p class="font-medium pt-2">第 {pageNo()} 页</p>

        {!picList.loading && pageNo() * pageSize < picList().total ? (
          <p
            class="w-20 relative inline-flex justify-center items-center rounded-md border border-gray-300 text-white px-4 py-2 text-sm font-medium bg-blue-500"
            onClick={() => (setPageNo((p) => p + 1))}
          >下一页</p>
        ) : <p class="w-20"/>}
      </div>
    </div>
  )
}

export default ImageHome

这个组件和图片上传组件可以放在一个页面里,也可以自行查询 SolidJS 关于路由的部分,去分两个页面实现。

本地预览一起正常后,提交代码即可触发自动构建部署,公网访问看看是否一切正常了。

删除图片

就当留个小练习吧,删除逻辑的代码被我隐藏掉了,但是其实很简单,注册个 /delete 路由,接收 id ,编写 sql 即可,请自行实现吧。

结语

好了以上就是全部内容,就可以获得一个10GB的OSS存储空间用于做图床,写博客之类的都可以更方便地贴图了。有问题欢迎留言欢迎交流,如果感兴趣的朋友多,后续可以考虑开源相关代码。