15 Commits

Author SHA1 Message Date
engine-labs-app[bot]
46da1fa495 docs(research): add comprehensive competitor research report for Zenfeed
Add a new markdown document providing a detailed research and comparison
of Zenfeed's competitor landscape, including SaaS/AI RSS readers,
self-hosted aggregators, automation tools, intelligence monitoring, and
more. This summary will guide product positioning, highlight feature
gaps, and define Zenfeed's unique value proposition relative to industry
alternatives.

The research supports future roadmap planning and can serve as a quick
reference for team members, partners, and contributors seeking market
context or evaluating differentiation strategy.
2025-10-19 15:17:56 +00:00
glidea
7cb8069d60 update README.md 2025-09-08 15:56:13 +08:00
glidea
87b84d94ff update README.md 2025-09-06 16:20:32 +08:00
glidea
4d29bae67f update README 2025-08-18 16:41:23 +08:00
glidea
d640e975bd handle empty response for gemini 2025-08-18 16:33:27 +08:00
glidea
e4bd0ca43b recommend Qwen/Qwen3-Embedding-4B by default 2025-07-24 10:14:09 +08:00
glidea
8b001c4cdf update image 2025-07-16 11:40:43 +08:00
glidea
6cacb47d3d update doc 2025-07-15 11:31:25 +08:00
glidea
a65d597032 update doc 2025-07-14 21:46:20 +08:00
glidea
151bd5f66f update sponsor 2025-07-14 21:32:43 +08:00
glidea
69a9545869 update doc 2025-07-14 18:12:17 +08:00
glidea
b01e07e348 fix doc 2025-07-14 12:28:52 +08:00
glidea
e92d7e322e allow empty config for object storage 2025-07-11 21:42:54 +08:00
glidea
7b4396067b fix ci 2025-07-09 21:47:23 +08:00
glidea
00c5dfadee add podcast 2025-07-09 17:28:26 +08:00
10 changed files with 244 additions and 79 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
custom: https://afdian.com/a/glidea

View File

@@ -5,6 +5,8 @@ on:
branches: [ main, dev ]
pull_request:
branches: [ main ]
release:
types: [ published ]
jobs:
test:
@@ -25,7 +27,7 @@ jobs:
build-and-push:
runs-on: ubuntu-latest
needs: test
if: github.event_name == 'push'
if: github.event_name == 'release' || (github.event_name == 'push' && github.ref_name == 'dev')
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
@@ -36,7 +38,7 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image (main)
if: github.ref_name == 'main'
if: github.event_name == 'release'
run: make push
- name: Build and push Docker image (dev)
if: github.ref_name == 'dev'

View File

@@ -113,7 +113,7 @@ Give zenfeed a try! **AI + RSS** might be a better way to consume information in
> [!IMPORTANT]
> zenfeed uses model services from [SiliconFlow](https://cloud.siliconflow.cn/en) by default.
> * Models: `Qwen/Qwen3-8B` (Free) and `Pro/BAAI/bge-m3`.
> * Models: `Qwen/Qwen3-8B` (Free) and `Qwen/Qwen3-Embedding-4B`.
> * If you don't have a SiliconFlow account yet, use this [**invitation link**](https://cloud.siliconflow.cn/i/U2VS0Q5A) to get a **¥14** credit.
> * If you need to use other providers or models, or for more detailed custom deployments, please refer to the [Configuration Documentation](https://github.com/glidea/zenfeed/blob/main/docs/config.md) to edit `docker-compose.yml`.

View File

@@ -1,3 +1,5 @@
[Nano Banana🍌 公益站](https://image-generation.zenfeed.xyz/):集成 Twitter 热门 Prompt轻松玩转各种姿势
---
[English](README-en.md)
---
@@ -9,13 +11,7 @@
* 面向开发者一站式接入几乎所有AI应用开发需要用到的模型和API一站式付费统一接入。
* 面向企业管理与使用界面分离一人管理多人使用降低中小企业使用AI的门槛和成本。
> 以下是我的个人感受
> * 播客生成效果不错,产品设计也不错,能基于 RSS 内容生成,修改播客脚本... 比市面其它播客产品更全面
> * 中转价格大多与官方一致,比如硅基 qwen3 8b 同样免费
> * 支持 MCPAgent 托管
> * 拥抱开源,平台上各种 AI 应用都是开源的。可以看他们的 [开源账户](https://github.com/302ai)
看都看了GitHub 一键登录 [注册一个](https://share.302.ai/mFS9MS) 试试吧!立即获得 1 美元额度
GitHub 一键登录 [注册一个](https://share.302.ai/mFS9MS) 试试吧!立即获得 1 美元额度
---
@@ -41,6 +37,8 @@ zenfeed 是你的 <strong>AI 信息中枢</strong>。它既是<strong>智能 RSS
<p align="center">
<a href="https://zenfeed.xyz"><b>在线体验 (仅 RSS 阅读)</b></a>
&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;
<a href="https://github.com/xusonfan/zenfeedApp"><b>安卓版体验 (仅 RSS 阅读)</b></a>
&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;
<a href="docs/tech/hld-zh.md"><b>技术文档</b></a>
&nbsp;&nbsp;&nbsp;|&nbsp;&nbsp;&nbsp;
<a href="#-安装与使用"><b>快速开始</b></a>
@@ -52,10 +50,19 @@ zenfeed 是你的 <strong>AI 信息中枢</strong>。它既是<strong>智能 RSS
---
**epub2rss**: 把 epub 电子书转成每日更新一个章节的 RSS Feed[join waitlist](https://epub2rss.pages.dev/)
**one-coffee**: 一款类似 syft万物追踪的日报产品差异点支持播客等多模态高质量信源主攻 AI 领域)。下方加我微信加入 waitlist
---
**赞助项目可以领取 Gemini Key**
<a href="https://afdian.com/a/glidea"><img src="docs/images/sponsor.png" width="500"></a>
<br/>
<a href="https://afdian.com/a/glidea">赞助项目,支持发展</a>
---
## 💡 前言
RSS简易信息聚合诞生于 Web 1.0 时代,旨在解决信息分散的问题,让用户能在一个地方聚合、追踪多个网站的更新,无需频繁访问。它将网站更新以摘要形式推送给订阅者,便于快速获取信息。
@@ -135,7 +142,8 @@ RSS简易信息聚合诞生于 Web 1.0 时代,旨在解决信息分散
> [!IMPORTANT]
> zenfeed 默认使用 [硅基流动](https://cloud.siliconflow.cn/) 提供的模型服务。
> * 模型: `Qwen/Qwen3-8B` (免费) 和 `Pro/BAAI/bge-m3`。
> * 模型: `Qwen/Qwen3-8B` (免费) 和 `Qwen/Qwen3-Embedding-4B`。
> * **!!!如果你愿意赞助本项目,将获赠一定额度的 Gemini 2.5 Pro/Flash!!! (见下方)**
> * 如果你还没有硅基账号,使用 [**邀请链接**](https://cloud.siliconflow.cn/i/U2VS0Q5A) 可获得 **14 元** 赠送额度。
> * 如果需要使用其他厂商或模型,或进行更详细的自定义部署,请参考 [配置文档](https://github.com/glidea/zenfeed/blob/main/docs/config-zh.md) 来编辑 `docker-compose.yml`。
@@ -171,6 +179,7 @@ $env:API_KEY = "sk-..."; docker-compose -p zenfeed up -d
> * **安全提示:** zenfeed 尚无认证机制,将服务暴露到公网可能会泄露您的 `API_KEY`。请务必配置严格的安全组规则,仅对信任的 IP 开放访问。
### 3. 开始使用
> 安卓版https://github.com/xusonfan/zenfeedApp
#### 添加 RSS 订阅源
@@ -206,14 +215,14 @@ $env:API_KEY = "sk-..."; docker-compose -p zenfeed up -d
<table>
<tr>
<td align="center">
<img src="docs/images/wechat.png" alt="Wechat QR Code" width="150">
<img src="docs/images/wechat.png" alt="Wechat QR Code" width="300">
<br>
<strong>加群讨论</strong>
<strong>AI 学习交流社群</strong>
</td>
<td align="center">
<img src="docs/images/sponsor.png" alt="Sponsor QR Code" width="150">
<img src="docs/images/sponsor.png" width="500">
<br>
<strong>请杯咖啡 🧋</strong>
<strong><a href="https://afdian.com/a/glidea">请杯奶茶 🧋</a></strong>
</td>
</tr>
</table>

View File

@@ -60,7 +60,7 @@ configs:
api_key: ${API_KEY:-your-api-key}
- name: embed
provider: siliconflow
embedding_model: Pro/BAAI/bge-m3
embedding_model: Qwen/Qwen3-Embedding-4B
api_key: ${API_KEY:-your-api-key}
scrape:
rsshub_endpoint: http://rsshub:1200

View File

@@ -0,0 +1,152 @@
# 竞品调研Zenfeed
更新时间2025-10基于公开已知信息与产品体验的综合判断可能随时间变化
目的:梳理 Zenfeed 所在赛道的主要竞品与替代方案,按产品类型与能力维度对比,识别差异化与机会点,指导路线和定位。
一、产品类型与代表选手
1) AI 加持的 RSS/SaaS 阅读器
- Feedly含 Feedly AI/Leo
- Readwise Reader
- Inoreader规则、搜索较强近年也引入部分 AI 功能)
- NewsBlur基于训练的过滤偏好
2) 自托管 RSS 聚合器(开源)
- FreshRSS
- Tiny Tiny RSSTT-RSS
- Miniflux
- + 插件生态(例如部分 OpenAI/总结插件)
3) 信息监控/自动化编排
- Huginn自托管 IFTTT/IF-This-Then-That
- n8n低代码自动化编排SaaS/自托管)
- changedetection.io网页变更监控侧重 DOM 变化)
- Distill.io / VisualpingSaaS 页面变更监控)
4) 情报监测/关键词追踪(论坛/社媒/开发者社区)
- Syften跨社区关键词监控
- 万物追踪(中文市场关注度较高)
- TeamWiseFlow/WiseFlow“AI 首席情报官”思路)
5) 爬取/采集与任务平台(更多是基础设施/补充)
- Scrapy、Apify、Crawlee、Playwright/Chromium 爬取方案
- RSSHub补齐数据源的通用 RSS 生成器,侧重“供给侧”,非直接竞品)
二、核心对比维度
- 数据源覆盖与可扩展性:
- 是否仅限 RSS是否支持 RSSHub/网页爬取、API、邮件等是否易于接入新源。
- 信息筛选与查询:
- 规则引擎(关键词/布尔条件/正则/评分)、保存的搜索、优先级/标签。
- AI 能力:
- 摘要、打标、主题聚类、意图识别、相似度搜索/去重、长文重写、对话式问答。
- 工作流/编排:
- 是否可声明式编排pipeline、分步处理、多路分发、可插拔 Prompt/算子。
- 通知与路由:
- 邮件/Slack/Telegram/企业微信/飞书/Webhook/RSS 二次生成,是否支持相似分组/摘要汇总。
- 存储与检索:
- 结构化索引、倒排索引、向量库、对象存储(音频/附件)、缓存与过期策略。
- 部署与成本:
- SaaS 订阅 vs 自托管API Key 成本、并发限速、隐私数据掌控。
- 协作与团队能力:
- Board/共享、标注/高亮、评论、权限与多用户。
- 开放接口:
- JSON-RPC/HTTP API、MCP Server、二次开发友好度。
- 体验与生态:
- Web/移动端、浏览器插件、自动化生态与第三方集成。
三、典型竞品速览
1) Feedly + Feedly AILeo
- 定位:面向专业/团队的信息监测与阅读AI“Leo”用于优先级、主题、去重与摘要。
- 优势:成熟度高,协作/Board 丰富,企业方案完善,数据源与集成较多。
- 局限:闭源、订阅价格较高;自定义度受限;自托管不可行;对中文开发者生态与私有化场景不友好。
2) Readwise Reader
- 定位:稍偏“稍后读/知识管理”;支持网页/邮件/订阅AI 摘要和高亮回顾。
- 优势:体验优秀、阅读流程顺滑、与 Readwise 知识库联动。
- 局限:闭源、偏个人阅读;自动化编排/告警监控能力有限;难以做灵活的规则路由。
3) Inoreader / NewsBlur
- 定位:传统 RSS 强者Inoreader 的规则、过滤、搜索很成熟NewsBlur 有“训练”机制。
- 优势:稳定可靠、生态与客户端广、学习成本低。
- 局限AI 能力与向量检索通常非核心;深度个性化编排和二次开发空间有限。
4) FreshRSS / TT-RSS / Miniflux自托管
- 定位:轻量的开源 RSS 聚合;可通过插件接 AI 摘要。
- 优势:自托管、可控、成本低;社区活跃。
- 局限:缺少原生的“语义处理流水线”和“查询/路由/通知”的深度耦合要实现“AI + 监控 + 通知”的端到端闭环需要较多自定义。
5) Huginn / n8n自动化
- 定位:通用自动化与数据流编排。
- 优势:灵活、组件多、可拉通任意 API 与存储。
- 局限非“RSS/情报”领域专用;需要自己拼装爬取、解析、去重、总结、相似分组、通知等环节,心智与运维成本较高。
6) changedetection.io / Distill.io / Visualping页面变更监控
- 定位:检测网页 DOM/文本变化,并告警。
- 优势:上手快、对不提供 RSS 的页面友好。
- 局限语义层理解弱AI 总结/聚类/多源关联较少;难以形成“知识库 + 查询”。
7) Syften / 万物追踪 / WiseFlow 等(情报监控)
- 定位:跨社区关键词/实体/主题跟踪,生成简报。
- 优势:场景契合“情报/监控/简报”SaaS 即开即用。
- 局限:闭源或私有;可编排性有限;数据可控性与隐私诉求难满足;对中文/特定垂直源支持取决于厂商。
8) RSSHub重要补充而非直接竞品
- 定位:把各种站点“喂成 RSS”。
- 价值:极大丰富数据源;与 Zenfeed 组合可形成“源→处理→查询→路由”的闭环。
四、Zenfeed 的差异化与定位
- 面向“AI + RSS/情报/监控”的自托管开源引擎:
- 声明式 YAML 配置 + 热更新;组件化框架,订阅 Watcher 实时生效。
- 处理管道以“标签集”为核心抽象,支持 LLM 驱动的摘要、打标、评分、过滤、聚类、脚本生成、抓取增强等。
- 存储侧内置主索引、倒排索引与向量索引NutsDB 缓存嵌入;可选 MinIO/S3 存储富媒体。
- 调度器支持基于语义/规则的查询生成“事件”;通知器做相似合并与 LLM 汇总,路由到 Email/Webhook 等。
- 对外提供 JSON-RPC/HTTP、MCP Server、导出 RSS易于二次集成。
- 与 SaaS 阅读器相比:更可定制、数据私有、可与私有模型/网络融合;缺点是需要自行部署与维护。
- 与通用自动化平台相比Zenfeed 在“RSS/情报管道”有更强的领域内建语义模型与数据结构;通用性略弱但落地更快。
- 与纯 RSS 聚合器相比:原生引入 LLM 语义处理、向量搜索、调度/路由/通知,强调“读写闭环”和“监控/简报”。
五、潜在短板与改进机会(相对竞品)
- 认证与多用户/权限:当前暴露到公网存在安全顾虑;完善 Auth、分权与审计将有助企业团队采用。
- GUI 配置与可视化编排YAML 心智强,建议提供可视化 Pipeline/路由设计器、规则调试器、Prompt 管理。
- 移动端/客户端生态:继续完善官方 Web/移动端体验;浏览器插件“保存到 Zenfeed/一键追踪”。
- 第三方通知与协作:拓展 Slack/Telegram/飞书/企业微信等官方集成;通知中的交互(反馈、标注)回写管道。
- 数据源扩展内置“RSSHub 模板化连接”、网页自动提取Readability/Boilerplate、邮件源、GitHub/Reddit 等常见垂直源。
- 模型与成本:
- 多模型策略(廉价 Embedding + 中等 Summarize + 高质少量 Deep Reasoning
- 限速/重试/缓存/去重更精细以优化成本;
- 插件式模型提供商抽象,方便企业接私有 LLM。
- 团队协作Board/共享视图、事件指派、评论、标签规范与知识沉淀。
六、目标用户与场景建议
- 高阶 RSS 用户、研究员/分析师、开发者、投资/BD、开源维护者、增长/品牌观察;
- 典型场景:
- 行业/竞品/技术情报监控与周报;
- 个性化“高质量源 + 语义过滤 + 摘要 + 去重 + 分发”;
- 会议/活动/政策/漏洞/版本发布等事件跟踪;
- 私有化部署的“团队情报中枢”。
七、结论(定位与策略)
- 定位:自托管、可编排的 AI 信息中枢“RSS × 语义处理 × 调度路由 × 通知”)。
- 策略:
1) 做强“声明式管道 + 语义检索 + 调度路由”的一体化体验;
2) 打通 RSSHub 与常见垂直源,降低“连接成本”;
3) 完善认证/多用户与 GUI 编排,提升团队与企业可用性;
4) 提供一组开箱即用的“行业模板/蓝图”(安全情报、开源监控、投研资讯、竞品监测、政策合规等);
5) 控制模型成本,提供可观测性(速率、花费、召回/精确度、摘要质量回馈)。
备注:以上信息仅用于产品规划参考,不构成对第三方产品的评价承诺。实际功能与定价以各产品官方为准。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 897 KiB

View File

@@ -46,7 +46,7 @@ llms:
首先,您需要在 Cloudflare R2 中创建一个存储桶Bucket。然后获取以下信息
- `endpoint`: 您的 R2 API 端点。通常格式为 `https://<account_id>.r2.cloudflarestorage.com`。您可以在 R2 存储桶的主页找到它。
- `endpoint`: 您的 R2 API 端点。通常格式为 `<account_id>.r2.cloudflarestorage.com`。您可以在 R2 存储桶的主页找到它。
- `access_key_id``secret_access_key`: R2 API 令牌。您可以在 "R2" -> "管理 R2 API 令牌" 页面创建。
- `bucket`: 您创建的存储桶的名称。
- `bucket_url`: 存储桶的公开访问 URL。要获取此 URL您需要将存储桶连接到一个自定义域或者使用 R2 提供的 `r2.dev` 公开访问地址。
@@ -56,7 +56,7 @@ llms:
```yaml
storage:
object:
endpoint: "https://<your_account_id>.r2.cloudflarestorage.com"
endpoint: "<your_account_id>.r2.cloudflarestorage.com"
access_key_id: "..."
secret_access_key: "..."
bucket: "zenfeed-podcasts"
@@ -77,7 +77,7 @@ storage:
- `speakers`: 定义播客的演讲者。
- `name`: 演讲者的名字。
- `role`: 演讲者的角色和人设,将影响脚本内容。
- `voice`: 演讲者的声音。请参考 [Google Cloud TTS 文档](https://cloud.google.com/text-to-speech/docs/voices) 获取可用的声音名称(例如 `en-US-Standard-C``en-US-News-N`
- `voice`: 演讲者的声音。请参考 [Gemini TTS 文档](https://ai.google.dev/gemini-api/docs/speech-generation#voices)
**示例 `config.yaml`:**
@@ -85,20 +85,23 @@ storage:
storage:
feed:
rewrites:
- source_label: "content"
label: "podcast_url"
- source_label: content # 基于原文
transform:
to_podcast:
llm: "openai-chat"
tts_llm: "gemini-tts"
transcript_additional_prompt: "请让对话更生动有趣一些。使用中文回复"
estimate_maximum_duration: 3m0s # 接近 3 分钟
transcript_additional_prompt: 对话引人入胜,流畅自然,拒绝 AI 味,使用中文回复 # 脚本内容要求
llm: xxxx # 负责生成脚本的 llm
tts_llm: gemini-tts # 仅支持 gemini tts推荐使用 https://github.com/glidea/one-balance 轮询
speakers:
- name: "主持人小雅"
role: "一位经验丰富、声音甜美、风格活泼的科技播客主持人。"
voice: "zh-CN-Standard-A" # 女声
- name: "技术评论员老王"
role: "一位对技术有深入见解、观点犀利的评论员,说话直接,偶尔有些愤世嫉俗。"
voice: "zh-CN-Standard-B" # 男声
- name: 小雅
role: >-
一位经验丰富、声音甜美、风格活泼的科技播客主持人。前财经记者、媒体人出身,因为工作原因长期关注科技行业,后来凭着热爱和出色的口才转行做了全职内容创作者。擅长从普通用户视角出发,把复杂的技术概念讲得生动有趣,是她发掘了老王,并把他‘骗’来一起做播客的‘始作俑者’。
voice: Autonoe
- name: 老王
role: >-
一位资深科技评论员,互联网老兵。亲身经历过中国互联网从草莽到巨头的全过程,当过程序员,做过产品经理,也创过业。因此他对行业的各种‘风口’和‘概念’有自己独到的、甚至有些刻薄的见解。观点犀利,一针见血,说话直接,热衷于给身边的一切产品挑刺。被‘忽悠’上了‘贼船’,表面上经常吐槽,但内心很享受这种分享观点的感觉。
voice: Puck
label: podcast_url
```
配置完成后Zenfeed 将在每次抓取到新文章时,自动执行上述流程。可以在通知模版中使用 podcast_url label或 Web 中直接收听Web 固定读取 podcast_url label若使用别的名称则无法读取

View File

@@ -21,6 +21,7 @@ import (
"io"
"reflect"
"strconv"
"strings"
"sync"
"time"
@@ -407,6 +408,9 @@ func (c *cached) String(ctx context.Context, messages []string) (string, error)
if err != nil {
return "", err
}
if strings.Trim(value, " \n\r\t") == "" {
return "", errors.New("empty response") // Gemini may occur this.
}
// TODO: reduce copies.
if err = c.kvStorage.Set(ctx, []byte(keyStr), []byte(value), 65*time.Minute); err != nil {

View File

@@ -19,7 +19,7 @@ import (
"context"
"io"
"net/url"
"reflect"
"strings"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
@@ -46,26 +46,50 @@ type Config struct {
Endpoint string
AccessKeyID string
SecretAccessKey string
Bucket string
BucketURL string
client *minio.Client
Bucket string
BucketURL string
bucketURL *url.URL
}
func (c *Config) Validate() error {
if c.Empty() {
return nil
}
if c.Endpoint == "" {
return errors.New("endpoint is required")
}
c.Endpoint = strings.TrimPrefix(c.Endpoint, "https://") // S3 endpoint should not have https:// prefix.
c.Endpoint = strings.TrimPrefix(c.Endpoint, "http://")
if c.AccessKeyID == "" {
return errors.New("access key id is required")
}
if c.SecretAccessKey == "" {
return errors.New("secret access key is required")
}
client, err := minio.New(c.Endpoint, &minio.Options{
Creds: credentials.NewStaticV4(c.AccessKeyID, c.SecretAccessKey, ""),
Secure: true,
})
if err != nil {
return errors.Wrap(err, "new minio client")
}
c.client = client
if c.Bucket == "" {
return errors.New("bucket is required")
}
if c.BucketURL == "" {
return errors.New("bucket url is required")
}
u, err := url.Parse(c.BucketURL)
if err != nil {
return errors.Wrap(err, "parse public url")
}
c.bucketURL = u
return nil
}
@@ -82,6 +106,10 @@ func (c *Config) From(app *config.App) *Config {
return c
}
func (c *Config) Empty() bool {
return c.Endpoint == "" && c.AccessKeyID == "" && c.SecretAccessKey == "" && c.Bucket == "" && c.BucketURL == ""
}
type Dependencies struct{}
// --- Factory code block ---
@@ -109,19 +137,6 @@ func new(instance string, app *config.App, dependencies Dependencies) (Storage,
return nil, errors.Wrap(err, "validate config")
}
client, err := minio.New(config.Endpoint, &minio.Options{
Creds: credentials.NewStaticV4(config.AccessKeyID, config.SecretAccessKey, ""),
Secure: true,
})
if err != nil {
return nil, errors.Wrap(err, "new minio client")
}
u, err := url.Parse(config.BucketURL)
if err != nil {
return nil, errors.Wrap(err, "parse public url")
}
return &s3{
Base: component.New(&component.BaseConfig[Config, Dependencies]{
Name: "ObjectStorage",
@@ -129,39 +144,40 @@ func new(instance string, app *config.App, dependencies Dependencies) (Storage,
Config: config,
Dependencies: dependencies,
}),
client: client,
bucketURL: u,
}, nil
}
// --- Implementation code block ---
type s3 struct {
*component.Base[Config, Dependencies]
client *minio.Client
bucketURL *url.URL
}
func (s *s3) Put(ctx context.Context, key string, body io.Reader, contentType string) (publicURL string, err error) {
ctx = telemetry.StartWith(ctx, append(s.TelemetryLabels(), telemetrymodel.KeyOperation, "Put")...)
defer func() { telemetry.End(ctx, err) }()
bucket := s.Config().Bucket
config := s.Config()
if config.Empty() {
return "", errors.New("not configured")
}
if _, err := s.client.PutObject(ctx, bucket, key, body, -1, minio.PutObjectOptions{
if _, err := config.client.PutObject(ctx, config.Bucket, key, body, -1, minio.PutObjectOptions{
ContentType: contentType,
}); err != nil {
return "", errors.Wrap(err, "put object")
}
return s.bucketURL.JoinPath(key).String(), nil
return config.bucketURL.JoinPath(key).String(), nil
}
func (s *s3) Get(ctx context.Context, key string) (publicURL string, err error) {
ctx = telemetry.StartWith(ctx, append(s.TelemetryLabels(), telemetrymodel.KeyOperation, "Get")...)
defer func() { telemetry.End(ctx, err) }()
bucket := s.Config().Bucket
config := s.Config()
if config.Empty() {
return "", errors.New("not configured")
}
if _, err := s.client.StatObject(ctx, bucket, key, minio.StatObjectOptions{}); err != nil {
if _, err := config.client.StatObject(ctx, config.Bucket, key, minio.StatObjectOptions{}); err != nil {
errResponse := minio.ToErrorResponse(err)
if errResponse.Code == minio.NoSuchKey {
return "", ErrNotFound
@@ -170,7 +186,7 @@ func (s *s3) Get(ctx context.Context, key string) (publicURL string, err error)
return "", errors.Wrap(err, "stat object")
}
return s.bucketURL.JoinPath(key).String(), nil
return config.bucketURL.JoinPath(key).String(), nil
}
func (s *s3) Reload(app *config.App) (err error) {
@@ -183,29 +199,7 @@ func (s *s3) Reload(app *config.App) (err error) {
return errors.Wrap(err, "validate config")
}
if reflect.DeepEqual(s.Config(), newConfig) {
log.Debug(ctx, "object storage config not changed")
return nil
}
client, err := minio.New(newConfig.Endpoint, &minio.Options{
Creds: credentials.NewStaticV4(newConfig.AccessKeyID, newConfig.SecretAccessKey, ""),
Secure: true,
})
if err != nil {
return errors.Wrap(err, "new minio client")
}
u, err := url.Parse(newConfig.BucketURL)
if err != nil {
return errors.Wrap(err, "parse public url")
}
s.client = client
s.bucketURL = u
s.SetConfig(newConfig)
log.Info(ctx, "object storage reloaded")
return nil