第十三章 阿里OSS集成指南:STS临时授权刷新、客户端上传

668 阅读12分钟

一、使用STS进行临时授权

OSS可以通过阿里云STS(Security Token Service)进行临时授权访问。阿里云STS是为云计算用户提供临时访问令牌的Web服务。通过STS,您可以为第三方应用或子用户(即用户身份由您自己管理的用户)颁发一个自定义时效和权限的访问凭证。关于STS的更多信息,请参见STS介绍

官方RAM控制台:ram.console.aliyun.com/roles

获取地域信息:help.aliyun.com/zh/oss/user…

官方文档教程:参考,按照步骤完成前4步,复制以下内容

复制账号的AccessKey与AccessKey Secret

复制自定义角色的ARN

我的配置,提前创建了一个叫treasure-web的bucket,给bucket内image和file目录上传文件的权限,这里增加了删除和读的权限,后续会用到

{
  "Version": "1",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "oss:PutObject",
        "oss:DeleteObject",
        "oss:GetObject"
      ],
      "Resource": [
        "acs:oss:*:*:treasure-web/image/*",
        "acs:oss:*:*:treasure-web/file/*"
      ]
    }
  ]
}

添加跨域规则,部署后来源填写域名即可

二、使用SDK

NodeSDK示例

1. 安装依赖

# oss
npm i ali-oss
npm i --save-dev @types/ali-oss
# 上传文件组件
npm i react-dropzone

2. 环境变量

.env添加配置,同时追加到.env.example

  • AccessKeyId:子账户的密钥Id
  • AccessKeySecret:子账户的密钥
  • RoleArn:获取临时访问凭证的角色的ARN
  • Bucket/NEXT_PUBLIC_Bucket:Bucket名称
  • Region/NEXT_PUBLIC_Region:地域名称
#oss
AccessKeyId="LTA..."
AccessKeySecret="kreE..."
RoleArn="acs:ram::1...:role/..."
Bucket="..."
Region="..."
NEXT_PUBLIC_Bucket="..."
NEXT_PUBLIC_Region="..."

3. 远程模式

上传到oss的图片需要展示,所以在next.config.js配置image.remotePatterns

{
    protocol: 'http',
    hostname: 'treasure-web.oss-cn-beijing.aliyuncs.com',
},

4. 上传组件

上传组件使用react-dropzone,可以点击或拖放上传文件,官方示例

import React, {useCallback} from 'react'
import {useDropzone} from 'react-dropzone'

function MyDropzone() {
  const onDrop = useCallback(acceptedFiles => {
    // Do something with the files
  }, [])
  const {getRootProps, getInputProps, isDragActive} = useDropzone({onDrop})

  return (
    <div {...getRootProps()}>
      <input {...getInputProps()} />
      {
        isDragActive ?
          <p>Drop the files here ...</p> :
          <p>Drag 'n' drop some files here, or click to select files</p>
      }
    </div>
  )
}

按照官方示例改造UploadAvatar组件,触发Drop后执行逻辑:

  • 判空并获取第一个文件
  • 文件类型大小判断
  • 获取临时凭证
  • 凭证正常开始上传
  • 上传后得到url,我们把filePath更新到数据库
// src/components/UploadAvatar.tsx
// 以下逻辑全部为新增
// 更新图片
const { mutate: startUpdateImage, isLoading: updateImageLoading } =
  trpc.updateImage.useMutation({
    onSuccess: (data) => {
      if (data?.image) {
        setImage(data.image ?? undefined)
      }
      toast({
        title: getMessages('10042'),
        description: getMessages('10043'),
        variant: 'default',
      })
    },
    onError: (error) => {
      toast({
        title: getMessages('10042'),
        description: error.message,
        variant: 'destructive',
      })
    },
  })
// 获取临时凭证
const { mutateAsync: asyncGetStsToken } = trpc.getStsToken.useMutation()
const onDrop = useCallback(
  async (acceptedFiles: File[]) => {
    if (acceptedFiles.length === 0) {
      return
    }
    if (updateImageLoading || removeImageLoading) {
      return
    }
    const file = acceptedFiles[0]
    // 检查文件类型,仅接受图片
    if (!file.type.startsWith('image/')) {
      toast({
        title: getMessages('10042'),
        description: getMessages('10046'),
        variant: 'destructive',
      })
      return
    }
    // 文件最大为5MB
    if (file.size > 5 * 1024 * 1024) {
      toast({
        title: getMessages('10042'),
        description: getMessages('10047'),
        variant: 'destructive',
      })
      return
    }
    // 获取临时凭证
    let stsToken = null
    try {
      const res = await asyncGetStsToken()
      stsToken = res
    } catch (error) {
      const trpcError = error as ManualTRPCError
      toast({
        title: getMessages('10042'),
        description: trpcError.message,
        variant: 'destructive',
      })
      return
    }
    // 使用临时凭证上传文件
    if (!stsToken) {
      return
    }
    try {
      // 生成随机的文件名
      const randomFileName =
        Math.random().toString(36).substring(2, 15) +
        '.' +
        file.type.split('/')[1]
      const filePath = 'image/' + randomFileName
      const url = await uploadOSS({
        file,
        filePath,
        ...stsToken,
      })
      if (url) {
        startUpdateImage({ image: filePath })
      }
    } catch (error) {
      toast({
        title: getMessages('10042'),
        description: getMessages('10045'),
        variant: 'destructive',
      })
      return
    }
  },
  [
    asyncGetStsToken,
    removeImageLoading,
    startUpdateImage,
    toast,
    updateImageLoading,
  ]
)

const { getRootProps, getInputProps } = useDropzone({ onDrop })

// JSX,给相应的可以点击和放置的元素设置getRootProps,增加一个input元素
<div
    {...getRootProps()}
    className="rounded-full w-full h-full flex items-center justify-center border border-zinc-200 relative bg-zinc-50 overflow-hidden cursor-pointer"
    >
    //...
    <input {...getInputProps()} />
</div>
// 删除图片增加判断
onClick={() => {
    if (updateImageLoading || removeImageLoading) {
      return
    }
    startRemoveImage()
}}

5. 封装oss服务端

封装三个方法:

  • getStsToken:获取临时凭证
  • getSignedUrl:url签名
  • deleteObject:删除文件

签名和删除都将给有权限的用户操作,在STS的assumeRole方法中,token的有效期是以秒为单位的。所以,如果你设置的有效期为3000秒,那么实际的有效期就是3000秒,也就是50分钟。

refreshSTSTokenInterval被设置为900000毫秒,也就是15分钟。这意味着在token过期前的15分钟,refreshSTSToken函数将被调用以刷新token。这样就能确保在当前的token过期之前,新的token已经被获取并可以使用了。

// src/lib/ossServer.ts
import OSS, { STS } from 'ali-oss'

const sts = new STS({
  // 填写步骤1创建的RAM用户AccessKey。
  accessKeyId: process.env.AccessKeyId!,
  accessKeySecret: process.env.AccessKeySecret!,
})
export const getStsToken = async () => {
  try {
    // roleArn填写步骤2获取的角色ARN,例如acs:ram::175708322470****:role/ramtest。
    // policy填写自定义权限策略,用于进一步限制STS临时访问凭证的权限。如果不指定Policy,则返回的STS临时访问凭证默认拥有指定角色的所有权限。
    // 临时访问凭证最后获得的权限是步骤4设置的角色权限和该Policy设置权限的交集。
    // expiration用于设置临时访问凭证有效时间单位为秒,最小值为900,最大值以当前角色设定的最大会话时间为准。本示例指定有效时间为3000秒。
    // sessionName用于自定义角色会话名称,用来区分不同的令牌,例如填写为sessiontest。
    const result = await sts.assumeRole(
      process.env.RoleArn!,
      ``,
      3000,
      'sessiontest'
    )
    return {
      AccessKeyId: result.credentials.AccessKeyId,
      AccessKeySecret: result.credentials.AccessKeySecret,
      SecurityToken: result.credentials.SecurityToken,
      Expiration: result.credentials.Expiration,
    }
  } catch (err) {
    return null
  }
}

export const getOssClient = async () => {
  const token = await getStsToken()

  if (token) {
    const client = new OSS({
      region: process.env.Region!,
      accessKeyId: token.AccessKeyId,
      accessKeySecret: token.AccessKeySecret,
      stsToken: token.SecurityToken,
      bucket: process.env.Bucket!,
      refreshSTSToken: async () => {
        const newToken = await getStsToken()
        if (newToken) {
          return {
            accessKeyId: newToken.AccessKeyId,
            accessKeySecret: newToken.AccessKeySecret,
            stsToken: newToken.SecurityToken,
          }
        } else {
          throw new Error('获取临时token失败,请重试')
        }
      },
      refreshSTSTokenInterval: 900000, // Start refreshing the token 15 minutes before it expires
    })

    return client
  } else {
    throw new Error('获取临时token失败,请重试')
  }
}

export const deleteObject = async (objectKey: string) => {
  const client = await getOssClient()
  // 填写Object完整路径。Object完整路径中不能包含Bucket名称。
  return client.delete(objectKey)
}

export const getSignedUrl = async (objectKey: string) => {
  const client = await getOssClient()
  return client.signatureUrl(objectKey, { expires: 3600 })
}

6. 封装oss客户端

封装上传文件的方法,由客户端执行,需要先拿到临时凭证的数据,然后上传

// src/lib/ossClient.ts
import OSS from 'ali-oss'
interface UploadProps {
  AccessKeyId: string
  AccessKeySecret: string
  SecurityToken: string
  file: File
  filePath: string
}

export const uploadOSS = async (data: UploadProps) => {
  const { file, AccessKeyId, AccessKeySecret, SecurityToken, filePath } = data
  const client = new OSS({
    // yourRegion填写Bucket所在地域。以华东1(杭州)为例,yourRegion填写为oss-cn-hangzhou。
    region: process.env.NEXT_PUBLIC_Region!,
    // 从STS服务获取的临时访问密钥(AccessKey ID和AccessKey Secret)。
    accessKeyId: AccessKeyId,
    accessKeySecret: AccessKeySecret,
    // 从STS服务获取的安全令牌(SecurityToken)。
    stsToken: SecurityToken,
    // 填写Bucket名称。
    bucket: process.env.NEXT_PUBLIC_Bucket!,
  })
  try {
    // 填写Object完整路径。Object完整路径中不能包含Bucket名称。
    // 例如example/test.txt。
    const result = await client.put(filePath, file)
    return result.url
  } catch (err) {
    return null
  }
}

7. API逻辑

获取临时凭证,直接调用服务端的getStsToken方法

// src/trpc/index.ts
getStsToken: privateProcedure.mutation(async () => {
  try {
    const data = await getStsToken()
    if (!data) {
      throw new ManualTRPCError('INTERNAL_SERVER_ERROR', getMessages('10044'))
    }
    return data
  } catch (error) {
    handleErrorforInitiative(error)
  }
}),

更新图片,给客户端返回签名后的image,否则没有权限访问

// src/trpc/index.ts
// 注意:保存到数据库的地址应该是从不包括bucket的完整路径,如上传到bucket下的image文件夹,路径就是/image/test.png
updateImage: privateProcedure
  .input(z.object({ image: z.string() }))
  .mutation(async ({ ctx, input }) => {
    try {
      const { userId } = ctx
      const { image } = input
      const originalUserInfo = await db.user.findUnique({
        where: {
          id: userId,
        },
      })
      const originalImage = originalUserInfo?.image
      if (originalImage) {
        await deleteObject(originalImage)
      }
      const userInfo = await db.user.update({
        where: {
          id: userId,
        },
        data: {
          image: image,
        },
      })
      const signedUrl = await getSignedUrl(image)
      return {
        id: userInfo.id,
        image: signedUrl,
      }
    } catch (error) {
      handleErrorforInitiative(error)
    }
  }),

查询用户信息时修改image

// src/trpc/index.ts
if (userInfo && userInfo.image) {
  userInfo.image = await getSignedUrl(userInfo.image)
}

最后补足删除图片时删除oss图片

if (originalImage) {
  await deleteObject(originalImage)
}

8. 补充文案

'10044': '获取临时凭证错误',
'10045': '头像上传失败',
'10046': '图片格式不正确',
'10047': '图片大小不能超过5M',

三、进度条

参考:help.aliyun.com/zh/oss/deve…

您可以通过简单上传(即putObject方式)将File对象、Blob数据以及OSS Buffer上传到OSS文件。简单上传时不支持使用进度函数。

所以,我们接下来处理一个接口没有进度条属性的情况

# 增加shadcn进度条组件
npx shadcn-ui@latest add progress

但是progress不能根据长度设置颜色,自定义一个属性indicatorColor

// src/components/ui/progress.tsx
'use client'

import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress'

import { cn } from '@/lib/utils'

type ProgressProps = React.ComponentPropsWithoutRef<
  typeof ProgressPrimitive.Root
> & {
  indicatorColor?: string
}

const Progress = React.forwardRef<
  React.ElementRef<typeof ProgressPrimitive.Root>,
  ProgressProps
>(({ className, value, indicatorColor, ...props }, ref) => (
  <ProgressPrimitive.Root
    ref={ref}
    className={cn(
      'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
      className
    )}
    {...props}
  >
    <ProgressPrimitive.Indicator
      className={cn(
        'h-full w-full flex-1 bg-primary transition-all',
        indicatorColor
      )}
      style={{
        transform: `translateX(-${100 - (value || 0)}%)`,
      }}
    />
  </ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName

export { Progress }

完成进度条:

  • 进度为0进度条不显示
  • 开始上传时开始定时任务,每500ms增加5%
  • 增加到95%时不再增加,如果没有完成一直处于这个状态
  • 如果提前完成了直接到100%
  • 完成后做定时是为了隐藏进度条
// src/components/UploadAvatar.tsx
// 定义进度条与计时
const [uploadProgress, setUploadProgress] = useState<number>(0)
const progressRefInterval = useRef<number | null>(null)
const startSimulatedProgress = () => {
    setUploadProgress(0)
    
    const interval = setInterval(() => {
      setUploadProgress((prevProgress) => {
        if (prevProgress >= 95) {
          clearInterval(interval)
          return prevProgress
        }
        return prevProgress + 5
      })
    }, 500)
    
    return interval as unknown as number
}
// 定义清除定时
const clearIntervals = (process: number) => {
    if (progressRefInterval.current) {
      clearInterval(progressRefInterval.current)
      progressRefInterval.current = null
    }
    setUploadProgress(process)
    if (process === 100) {
      setTimeout(() => {
        setUploadProgress(0)
      }, 2000)
    }
}
// 卸载清空定时
useEffect(() => {
    return () => {
      if (progressRefInterval.current) {
        clearInterval(progressRefInterval.current)
        progressRefInterval.current = null
      }
    }
}, [])

// 进度条组件
{uploadProgress !== 0 && (
    <Progress
      indicatorColor={uploadProgress === 100 ? 'bg-green-500' : ''}
      value={uploadProgress}
      className="h-2 w-64 mt-2 bg-zinc-200"
    />
  )}

最后在关键位置开始计时与清空计时

// 获取临时凭证前开始计时
progressRefInterval.current = startSimulatedProgress()
// 最终更新成功后清除定时,1处
clearIntervals(100)
// 上传过程中的错误处理,4处(3catch,1没有获取到凭证)
clearIntervals(0)

前面处理了上传和删除的禁用,结合进度条完善禁用

// 删除/更新
if (updateImageLoading || removeImageLoading || uploadProgress !== 0) {
  return
}

四、客户端刷新sts

// src/lib/ossClient.ts
import OSS from 'ali-oss'
interface UploadProps {
  AccessKeyId: string
  AccessKeySecret: string
  SecurityToken: string
  file: File
  filePath: string
}

export const uploadOSS = async (
  data: UploadProps,
  clientGetStsToken: () => Promise<{
    AccessKeyId: string
    AccessKeySecret: string
    SecurityToken: string
    Expiration: string
  } | null>
) => {
  const { file, AccessKeyId, AccessKeySecret, SecurityToken, filePath } = data
  const ossClient = new OSS({
    region: process.env.NEXT_PUBLIC_Region!,
    accessKeyId: AccessKeyId,
    accessKeySecret: AccessKeySecret,
    stsToken: SecurityToken,
    bucket: process.env.NEXT_PUBLIC_Bucket!,
    refreshSTSToken: async () => {
      try {
        const newToken = await clientGetStsToken()
        if (newToken) {
          return {
            accessKeyId: newToken.AccessKeyId,
            accessKeySecret: newToken.AccessKeySecret,
            stsToken: newToken.SecurityToken,
          }
        } else {
          throw new Error('Failed to refresh STS token')
        }
      } catch (err) {
        console.error(err)
        throw new Error('Failed to refresh STS token')
      }
    },
    refreshSTSTokenInterval: 900000, // Start refreshing the token 15 minutes before it expires
  })
  try {
    const result = await ossClient.put(filePath, file)
    return result.url
  } catch (err) {
    return null
  }
}

更新调用,增加第二个参数

const url = await uploadOSS(
  {
    file,
    filePath,
    ...stsToken,
  },
  async () => {
    const ret = await asyncGetStsToken()
    if (ret) {
      return ret
    }
    return null
  }
)

五、创建迁移文件

接下来生成迁移,并提交项目到git,为部署做准备。

在开发过程中,随着数据库模式的改变,确实可能会产生许多的迁移文件

这是正常的,因为每次你修改数据库模式并运行迁移时,Prisma 都会创建一个新的迁移文件。这些文件记录了数据库结构的历史变化,可以帮助你理解数据库模式如何随时间变化。

然而,你不需要过于担心迁移文件的数量。虽然在大型项目中,迁移文件可能会变得很多,但是这些文件通常很小,并且只在运行迁移时被使用。在大多数情况下,这些文件的数量不会对你的项目或数据库性能产生负面影响。

如果你觉得迁移文件太多,难以管理,你可以考虑合并一些相关的迁移。但是,这需要谨慎处理,因为合并迁移可能会改变数据库的状态,可能会导致问题。在合并迁移之前,最好在一个安全的环境中(例如备份的数据库或开发环境)测试迁移,以确保它们的行为符合预期。

在生产环境中,你通常只会运行 prisma migrate deploy 命令,这个命令会自动处理所有未应用的迁移,所以你不需要担心手动管理迁移文件。

在进行数据库迁移时,有多种可能导致数据丢失的情况。以下是一些常见的例子

  1. 删除表:如果你删除了一个表,那么该表中的所有数据都会被删除。
  2. 删除列:如果你删除了一个列,那么该列中的所有数据都会被删除。
  3. 修改列类型:如果你更改了一个列的类型,例如从字符串类型更改为整数类型,那么如果原始数据无法转换为新的类型,原始数据可能会丢失。
  4. 添加非空字段:如果你在一个已经包含数据的表中添加了一个新的、必填(非空)的字段,并且没有为该字段提供默认值,那么在尝试插入新记录时可能会出现错误,因为新的字段没有值。
  5. 主键更改:如果你更改了表的主键,那么可能会导致数据丢失。这是因为主键用于唯一标识记录,如果你更改了主键,可能会导致数据的一致性问题。
  6. 外键约束:如果你删除或更改了外键约束,可能会导致数据丢失。例如,如果你有一个外键约束,该约束要求在父表中必须存在一个匹配的记录,那么在删除父表中的记录时,可能会导致子表中的相关记录被删除。
  7. 修改数据:如果你在迁移过程中执行了修改数据的操作,例如更新或删除操作,那么可能会导致数据丢失。
  8. 并发问题:如果你在迁移过程中没有正确处理并发问题,可能会导致数据丢失。例如,如果你在迁移过程中同时修改了同一条记录,可能会导致数据的一致性问题。

以上就是一些可能导致数据丢失的情况。在进行数据库迁移时,你应该非常小心,以避免这些问题。在实施任何可能影响到数据的更改之前,你应该备份你的数据,并在一个与生产环境相似的测试环境中测试你的迁移。

npx prisma migrate dev --name init

六、Fix

页面是通过 HTTPS 加载的,但尝试加载一个 HTTP 资源,这是不安全的。现代浏览器会阻止这种“混合内容”加载,因为它会降低整个页面的安全性。

所以在ossClient和ossServer实例化OSS的时候添加属性:

secure: true, // 确保使用 HTTPS

配置nextl.conf.js的protocol设置为https

images: {
  remotePatterns: [
    {
      protocol: 'https',
      hostname: 'treasure-web.oss-cn-beijing.aliyuncs.com',
    },
  ],
},

升级策略,不允许使用http访问

教程示例:基于RAM Policy实现某个用户仅通过HTTPS方式访问OSS资源

{
    "Version": "1",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "oss:PutObject",
                "oss:DeleteObject",
                "oss:GetObject"
            ],
            "Resource": [
                "acs:oss:*:*:treasure-web/image/*",
                "acs:oss:*:*:treasure-web/file/*"
            ]
        },
        {
            "Effect": "Deny",
            "Action": "oss:*",
            "Resource": "acs:oss:*:*:*",
            "Condition": {
                "Bool": {
                    "acs:SecureTransport": [
                        "false"
                    ]
                }
            }
        }
    ]
}