Polar 文件服务(File Service)详解:基于 AWS S3 的分片上传、预签名下载与恶意文件检测实现
【免费下载链接】polarPolar — A billing platform for the intelligence era项目地址: https://gitcode.com/GitHub_Trending/po/polar
导读
Polar 是一个面向"智能时代"的开源计费平台,其文件服务(File Service)承载着托管下载权益(downloadables benefit)、产品媒体图、组织头像、工单附件等全部文件类能力。本篇文章以 server/polar/file/README.md 为主体骨架,完整还原其本地开发环境从零配置 AWS S3 的四个步骤(建桶 → 配 CORS → 建 IAM 用户与策略 → 配置 Polar 环境变量),并深入到仓库源码层(file/service.py、s3.py、endpoints.py、integrations/aws/s3/service.py以及 Web 端Upload.ts),剖析其分片上传、预签名 URL、四类文件服务类型与 GuardDuty 恶意文件检测的完整实现原理。读完本文,你将掌握如何在本地为 Polar 配置 AWS S3 存储,并理解其文件生命周期从创建到下载的完整数据流。
一、文件服务的定位与架构概览
从 server/polar/file/README.md 开头可以看到,文件服务的职责非常聚焦:在 AWS S3 上完成文件的存储、管理与访问,目前主要用于 Polar 托管的下载权益(hosted downloads benefit)。
在仓库中,文件服务以独立 Python 模块形式存在于 server/polar/file/,由以下核心文件构成:
- service.py:业务编排层,负责列出、查询、创建、完成上传、下载与删除文件;
- s3.py:S3 服务注册表,按文件服务类型映射到不同的存储桶;
- endpoints.py:FastAPI 路由层,暴露
/v1/files/*的 REST 接口; - schemas.py:Pydantic 请求/响应模型,含四种文件类型的差异化校验;
- repository.py:基于 SQLAlchemy 的持久化查询层;
- tasks.py:异步任务,处理 AWS GuardDuty 恶意文件扫描结果;
- auth.py:接口鉴权依赖(
files_read/files_write两个 OAuth scope)。
从数据模型看,文件元数据持久化在 PostgreSQL 的files表(见 server/polar/models/file.py),而文件内容本身存放在 S3 对象存储中;数据库字段(path、checksum_etag、checksum_sha256_base64、storage_version等)与 S3 对象一一对应,是一种典型的"元数据入库、二进制入桶"的双存储架构。
四类文件服务类型
server/polar/models/file.py 通过FileServiceTypes枚举定义了四种服务类型,它们分别映射到不同的存储桶与访问策略:
| 服务类型 | 用途 | 存储桶(默认) | 访问方式 |
|---|---|---|---|
downloadable | 托管下载权益(付费后发放的下载文件) | polar-s3(私有桶) | 预签名 URL |
product_media | 商品媒体图 | polar-s3-public(公开桶) | 公开 URL |
organization_avatar | 组织头像 | polar-s3-public(公开桶) | 公开 URL |
support_case_attachment | 工单附件(敏感,不对外开放) | polar-s3(私有桶) | 仅预签名 URL,禁止经文件 API 下载/修改/删除 |
这种类型到桶的映射定义在 server/polar/file/s3.py:私有数据(可下载文件、工单附件)统一进私有桶,通过短期预签名 URL 访问;公开媒体(产品图、头像)进公开桶,可直接拼出公开 URL 供 CDN/浏览器加载。从源码注释可以看到,support_case_attachment属于"敏感附件",必须走预签名 URL,不能暴露公开链接。
二、本地开发环境配置 AWS S3(README 原文步骤完整展开)
README 的"Development Configuration"章节给出了一套可选的本地开发配置流程。下面按原文的四个步骤完整展开,并补充必要的细节说明。
第 1 步:创建 AWS 账号
这是所有后续步骤的前提。你需要一个 AWS 账号来完成 S3 桶、IAM 用户与访问密钥的创建。该步骤在 README 中仅一行带过,但它是本地联调 S3 文件上传/下载的硬性前置条件。
第 2 步:创建 S3 存储桶(Bucket)
创建存储桶时,README 给出了两个关键要求:
- Block all public access(阻止所有公共访问):这是安全默认项。即使你的文件最终通过"公开 URL"访问,桶级别也应默认全部封闭,通过对象级策略或预签名 URL 精确控制访问范围,避免桶被整体暴露。
- 配置 CORS(跨域资源共享):浏览器端分片上传(
PUT请求)是跨域直连 S3 的,因此桶必须允许来自本地开发服务器的跨域请求。
README 提供的 CORS 配置如下(原样):
[ { "AllowedHeaders": [ "*" ], "AllowedMethods": [ "PUT", "HEAD" ], "AllowedOrigins": [ "http://127.0.0.1:3000" ], "ExposeHeaders": [ "ETag" ] } ]逐项说明这张配置的含义:
AllowedMethods只开放PUT与HEAD:PUT用于把分片直传 S3,HEAD用于服务端完成上传后校验对象元数据(见 integrations/aws/s3/service.py 的get_head_or_raise)。这里刻意不放GET,因为读取一律走预签名 URL 或公开 URL,不需要也不应该开启浏览器直读。AllowedOrigins指向http://127.0.0.1:3000:这是本地 Web 开发服务器(Next.js)的默认地址。若你的前端跑在其他端口,需要同步修改。ExposeHeaders暴露ETag:Web 端完成每个分片的PUT后,需要从响应头读取ETag作为该分片的校验标记(见下文 Web 端上传流程),因此必须显式暴露该响应头,否则浏览器脚本读不到。
第 3 步:创建 IAM 用户与策略
创建 IAM 策略(Policy)
README 要求先创建一个 IAM 策略,策略名称与 S3 桶名保持一致,策略 JSON 如下(原样):
{ "Version": "2012-10-17", "Statement": [ { "Sid": "VisualEditor0", "Effect": "Allow", "Action": [ "s3:PutObject", "s3:GetObjectAttributes", "s3:GetObject", "s3:GetObjectVersion", "s3:GetObjectVersionAttributes", "s3:DeleteObject", "s3:DeleteObjectVersion" ], "Resource": "arn:aws:s3:::<S3_BUCKET_NAME>/*" } ] }注意Resource中的<S3_BUCKET_NAME>需要替换为你的真实桶名。对照源码,这些 Action 与 Polar 文件服务的实际调用一一对应:
s3:PutObject:直传分片(upload_part)以及服务端直接上传(service.py 的upload,用于发票、导出文件等后端生成文件)都会用到;s3:GetObject/s3:GetObjectVersion:预签名下载 URL 与公开 URL 的底层操作;s3:DeleteObject:删除文件时调用(service.py 的delete_file);s3:GetObjectAttributes/s3:GetObjectVersionAttributes:读取对象属性,完成上传后获取ETag、LastModified等元数据。
策略作用范围限定在arn:aws:s3:::<S3_BUCKET_NAME>/*(桶内所有对象),遵循最小权限原则。
创建 IAM 用户(User)
- 名称:与 S3 桶名保持一致;
- 策略:选择上一步创建的策略直接附加;
- 凭据:用户创建完成后,进入
Security credentials(安全凭据)页面,滚动到Access keys(访问密钥)区域生成访问密钥对(Access Key ID + Secret Access Key)。
生成的访问密钥对将填入 Polar 的环境变量中,作为服务端访问 S3 的凭证。
第 4 步:配置 Polar 设置(.env)
最后一步是把上面创建的桶名、访问密钥等写入 Polar 的.env,覆盖POLAR_AWS_*相关配置。README 原文如下:
Update your
.envfor thePOLAR_AWS_*settings to contain the credentials, bucket name etc from above.
结合 server/polar/config.py 的源码,这一组配置项的实际名称与默认值如下:
| 环境变量 | 默认值 | 说明 |
|---|---|---|
POLAR_AWS_ACCESS_KEY_ID | polar-development | IAM 用户的 Access Key ID |
POLAR_AWS_SECRET_ACCESS_KEY | polar123456789 | IAM 用户的 Secret Access Key |
POLAR_AWS_REGION | us-east-2 | S3 桶所在区域,需与桶保持一致 |
POLAR_AWS_SIGNATURE_VERSION | v4 | AWS 签名版本(SigV4) |
POLAR_S3_FILES_BUCKET_NAME | polar-s3 | 私有文件桶名 |
POLAR_S3_FILES_PUBLIC_BUCKET_NAME | polar-s3-public | 公开文件桶名 |
POLAR_S3_FILES_PRESIGN_TTL | 3600(60 分钟) | 预签名 URL 有效期(秒) |
POLAR_S3_ENDPOINT_URL | 空 | 本地可指向 MinIO(http://127.0.0.1:9000)等 S3 兼容服务 |
POLAR_S3_PUBLIC_ENDPOINT_URL | 空 | 生成给浏览器使用的预签名 URL 的端点;本地开发时需与S3_ENDPOINT_URL区分(见下文说明) |
需要特别说明的是环境变量前缀机制:Polar 的Settings类通过SettingsConfigDict(env_prefix="polar_", case_sensitive=False)读取配置(见 config.py),所以源码中的S3_FILES_BUCKET_NAME在.env里对应POLAR_S3_FILES_BUCKET_NAME,依此类推。本地默认值可以直接连通一个开箱即用的本地 S3 环境(polar-development/polar123456789),这意味着仓库还提供了基于 MinIO 的本地联调方案。
关于S3_ENDPOINT_URL与S3_PUBLIC_ENDPOINT_URL的区别,config.py 的注释解释得很清楚:在本地用 Docker 起 MinIO 时,服务端在容器网络内访问 MinIO 用http://minio:9000,而生成给浏览器用的预签名 URL 必须让宿主机也能访问,因此要用http://localhost:9000。生产环境两者都是真实的 AWS 端点,无需区分。
三、文件生命周期:从创建到下载的完整链路
配置好 AWS 之后,文件服务在运行时是如何工作的?结合 endpoints.py 与 service.py 可以还原完整的数据流。
阶段一:创建文件(生成预签名分片上传)
客户端调用POST /v1/files/(endpoints.py),携带FileCreate负载。服务端 generate_presigned_upload 的执行流程为:
- 按
create_schema.service从S3_SERVICES选出对应的 S3 服务; - 调用
create_multipart_upload向 S3 发起多段上传初始化(integrations/aws/s3/service.py),生成对象路径{namespace}/{organization_id}/{file_uuid}/{name}——每个组织一个目录、每个文件一个 UUID 目录,因此允许不同文件同名共存; - 为客户端声明的每个分片生成一个预签名 PUT URL(
generate_presigned_upload_parts,有效期为presign_ttl); - 在
files表写入一条is_uploaded=False的元数据记录,随响应返回上传 ID 与分片 URL 列表。
该步骤要求调用方具备products_manage组织权限(assert_organization_permission),且 OAuth 需要files_writescope(见 auth.py)。
阶段二:客户端直传分片到 S3
拿到预签名 URL 后,浏览器绕过 Polar 后端,直接把文件分片PUT到 S3(这就是第 2 步配置 CORS 的原因)。Web 端实现位于 clients/apps/web/src/components/FileUpload/Upload.ts:
- 以
CHUNK_SIZE = 10MB将文件切块(Upload.ts),逐块计算 SHA-256; - 在浏览器端用 hash-wasm 计算整文件 SHA-256 与每个分片的 SHA-256(
getMultiparts); - 串行上传各分片(
uploadMultiparts)。注释明确解释了为何不能并发:S3 会对 SHA-256 做校验,且按文档要求分片必须按顺序接收,乱序会返回 400(Upload.ts); - 每个分片成功后从响应头读取
ETag,与分片号、分片 SHA-256 一起保存,最后调用POST /v1/files/{id}/uploaded完成整个上传。
阶段三:完成上传(服务端校验并落库)
POST /v1/files/{id}/uploaded(endpoints.py)调用 complete_upload:
- 通过
complete_multipart_upload告知 S3 所有分片已就绪(integrations/aws/s3/service.py),S3 据此组装完整对象; - 服务端
head_object取回最终对象元数据,把ETag、LastModified、storage_version写回files表; - 将
is_uploaded置为True——此后文件才对外可见可下载(list接口默认只返回is_uploaded的文件,见 service.py)。
对应地,server/tests/file/test_endpoints.py 验证了"未完成上传时 S3 对象不可用"的行为:只创建不上传、不 complete,读取对象会抛出S3FileError。
阶段四:下载与公开访问
- 私有文件(downloadable):调用
GET /v1/files/{id}/download(endpoints.py)获得一个预签名下载 URL。服务端 generate_presigned_download_url 在签名参数中携带ResponseContentDisposition(保证浏览器以下载而非内联方式处理,正确处理非 ASCII 文件名)与ResponseContentType,URL 有效期默认 60 分钟(POLAR_S3_FILES_PRESIGN_TTL)。 - 公开文件(product_media / organization_avatar):响应模型通过
public_url计算字段直接生成公开 URL(见 schemas.py)。底层用UNSIGNED签名客户端生成ExpiresIn=0的 URL(integrations/aws/s3/service.py),本质是拼出一个不带签名的公开访问地址。
值得注意的安全细节:下载接口在返回 URL 前还会检查flagged_malicious_at,被判定为恶意的文件直接拒绝下载(endpoints.py)。
阶段五:更新与删除
PATCH /v1/files/{id}仅允许修改name与version两个元数据字段(service.py);DELETE /v1/files/{id}执行软删除(置deleted_at),同时删除ProductMedia关联记录并调用 S3delete_object清理对象(service.py);- 以上两个接口均通过
_assert_mutable拒绝工单附件(support_case_attachment)的修改与删除——工单附件属于工单记录的一部分,只能经工单接口创建,不通过文件 API 变更(endpoints.py)。
四、四种文件类型的差异化校验规则
不同服务类型对 MIME 类型和大小有着完全不同的约束,这些约束以 Pydantic 校验形式定义在 schemas.py,创建文件时后端即会强校验:
| 服务类型 | 允许的 MIME 类型 | 大小上限 |
|---|---|---|
downloadable | 不限 | 不限(仅受 S3 与分片数约束) |
product_media | 仅图片:image/jpeg、image/png、image/gif、image/webp、image/svg+xml | 10 MB |
organization_avatar | 仅图片:image/jpeg、image/png、image/gif、image/webp、image/svg+xml | 1 MB |
support_case_attachment | 图片、视频(mp4/quicktime/webm)、PDF、CSV、纯文本、Word、Excel | 250 MB |
这些规则分别定义在 schemas.py(商品媒体图)、L49-L64(头像)与 L67-L92(工单附件)。此外,创建请求对分片数量也有上限约束——test_endpoints.py 验证了提交超过 1 万个分片会被拒绝(HTTP 422,too_long校验错误),防止恶意构造超大分片列表。
FileCreate与FileRead均采用 Pydantic判别联合(Discriminated Union):以service字段作为判别器,客户端传什么service,就自动套用对应的校验规则与响应模型(schemas.py)。数据模型层面,File基类通过polymorphic_on: "service"实现了 SQLAlchemy 单表继承,四个子类(DownloadableFile、ProductMediaFile、OrganizationAvatarFile、SupportCaseAttachmentFile)共用files表(models/file.py)。
五、安全机制:GuardDuty 恶意文件检测
文件服务内置了与 AWS GuardDuty(S3 恶意文件检测)联动的闭环:
- GuardDuty 扫描上传到 S3 桶的对象,发现风险后投递扫描结果事件;
- Polar worker 通过异步任务
file.guardduty_scan_result(tasks.py)接收事件; - 任务首先校验事件中的桶名是否属于
POLAR_S3_FILES_BUCKET_NAME或POLAR_S3_FILES_PUBLIC_BUCKET_NAME(防止伪造/误投递),再按对象 key 反查files表; - 命中后调用 flag_malicious,将
flagged_malicious_at与扫描详情写入数据库,并向组织成员发送maintainer_file_flagged_malicious通知。
文件被标记后即被"禁用消费":下载接口直接拒绝(HTTP 403),商品媒体选择接口也通过flagged_malicious_at.is_(None)条件排除被标记文件(service.py)。该任务以TaskPriority.MEDIUM优先级投递(tasks.py),对应的异步任务测试位于 server/tests/file/test_tasks.py。
六、接口与权限一览
文件服务的所有接口都以/v1/files为前缀(endpoints.py),汇总如下:
| 方法 | 路径 | 摘要 | 权限 |
|---|---|---|---|
GET | /v1/files/ | 列出文件(支持按组织、按 ID 过滤与分页) | files_read/files_write |
POST | /v1/files/ | 创建文件并返回预签名分片上传参数 | files_write+products_manage |
POST | /v1/files/{id}/uploaded | 完成分片上传 | files_write+products_manage |
GET | /v1/files/{id}/download | 获取预签名下载 URL | files_read/files_write |
PATCH | /v1/files/{id} | 更新文件名/版本(工单附件除外) | files_write+products_manage |
DELETE | /v1/files/{id} | 软删除并清理 S3 对象(工单附件除外) | files_write+products_manage |
鉴权上,auth.py 定义了两个依赖:FileRead需要files_read或files_writescope 之一,FileWrite严格要求files_write;主体(subject)可以是User或Organization。写操作还需组织级products_manage权限(endpoints.py),未认证请求一律 401(见 test_endpoints.py)。列表接口还通过get_accessible_org_ids做数据隔离,普通用户只能看到自己有权限组织的文件(service.py)。
结语
Polar 的文件服务是一个典型的"小而完整"的 S3 集成案例:四个步骤即可在本地完成 AWS 配置(建桶 → CORS → IAM →.env),运行时则通过服务端签发预签名分片 URL + 浏览器直传 S3 + 服务端完成校验的架构,既避免了文件流量经过应用服务器,又通过 SHA-256 校验与 GuardDuty 检测保障了数据完整性与安全性。如果你想深入阅读实现细节,建议从 server/polar/file/s3.py(桶映射)和 clients/apps/web/src/components/FileUpload/Upload.ts(浏览器端分片上传)这两个端点入手,配合 server/tests/file/test_endpoints.py 中的测试用例理解完整行为。
【免费下载链接】polarPolar — A billing platform for the intelligence era项目地址: https://gitcode.com/GitHub_Trending/po/polar
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考