mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-03 13:52:56 +00:00
更新项目架构与存储适配器,添加用户认证功能
本次提交包含以下主要更改: 1. 更新 `.gitignore` 文件,添加对 `node_modules` 和环境变量文件的忽略。 2. 修改 `.gitmodules` 文件,替换为新的子模块 `cloudflare-worker`。 3. 新增 `ARCHITECTURE.md` 和 `PROJECT_REFACTOR_PLAN.md` 文档,详细描述项目架构和改造计划。 4. 实现用户认证功能,添加 GitHub OAuth 处理逻辑,支持 JWT 生成与解析。 5. 引入新的存储接口 `CanvasStore`,并实现相应的存储逻辑,支持用户画布的增删改查。 6. 更新 `main.go` 文件,整合新的认证与存储逻辑,优化路由设置。 这些更改旨在提升项目的可扩展性与用户体验,支持多用户环境下的画布管理与存储。
This commit is contained in:
@@ -0,0 +1,220 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
# Excalidraw-Complete 架构文档
|
||||||
|
|
||||||
|
本文档旨在详细阐述 `excalidraw-complete` 项目的系统架构、技术栈、模块设计和数据流,以便于开发者理解、维护和进行二次开发。
|
||||||
|
|
||||||
|
## 1. 概述 (Overview)
|
||||||
|
|
||||||
|
`excalidraw-complete` 是一个将优秀的开源白板工具 [Excalidraw](https://github.com/excalidraw/excalidraw) 进行整合与封装的自托管解决方案。其核心目标是简化 Excalidraw 的私有化部署流程,将前端UI、后端数据存储和实时协作服务打包成一个单一的、易于部署的Go二进制文件。
|
||||||
|
|
||||||
|
**核心特性:**
|
||||||
|
|
||||||
|
- **一体化部署**:将所有服务打包成单个可执行文件,无需复杂的依赖配置。
|
||||||
|
- **可插拔存储**:通过环境变量支持多种数据持久化方案,包括内存、本地文件系统、SQLite和AWS S3。
|
||||||
|
- **实时协作**:内置基于 Socket.IO 的实时协作服务器,允许多个用户同时在同一个画板上工作。
|
||||||
|
- **Firebase 兼容层**:提供一个内存实现的 Firebase API 兼容层,以满足 Excalidraw 前端对 Firebase 的部分依赖。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 技术栈 (Tech Stack)
|
||||||
|
|
||||||
|
项目采用了现代化的前后端技术栈。
|
||||||
|
|
||||||
|
### 后端 (Backend)
|
||||||
|
|
||||||
|
- **语言**: [Go](https://go.dev/) (v1.21+)
|
||||||
|
- **Web框架**: [Chi (v5)](https://github.com/go-chi/chi) - 一个轻量级、高性能的 Go HTTP 路由器。
|
||||||
|
- **实时通信**: [Socket.IO for Go](https://github.com/zishang520/socket.io/v2) - 实现了 Socket.IO 协议,用于实时协作。
|
||||||
|
- **数据库驱动**:
|
||||||
|
- [go-sqlite3](https://github.com/mattn/go-sqlite3) - 用于 SQLite 存储。
|
||||||
|
- [aws-sdk-go-v2](https://github.com/aws/aws-sdk-go-v2) - 用于与 AWS S3 交互。
|
||||||
|
- **日志**: [Logrus](https://github.com/sirupsen/logrus) - 结构化的日志记录库。
|
||||||
|
- **ID生成**: [ULID](https://github.com/oklog/ulid) - 用于生成唯一、可排序的文档ID。
|
||||||
|
|
||||||
|
### 前端 (Frontend)
|
||||||
|
|
||||||
|
- **核心**: [Excalidraw](https://github.com/excalidraw/excalidraw) (作为 Git Submodule)
|
||||||
|
- **框架**: [React](https://reactjs.org/)
|
||||||
|
- **构建工具**: [Vite](https://vitejs.dev/)
|
||||||
|
- **语言**: [TypeScript](https://www.typescriptlang.org/)
|
||||||
|
|
||||||
|
### 构建与部署 (Build & Deployment)
|
||||||
|
|
||||||
|
- **容器化**: [Docker](https://www.docker.com/) & `Dockerfile`
|
||||||
|
- **构建自动化**: Go Build Tools, npm/yarn
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 系统架构 (System Architecture)
|
||||||
|
|
||||||
|
`excalidraw-complete` 是一个典型的**单体架构 (Monolith)**,但内部逻辑分层清晰。
|
||||||
|
|
||||||
|
```
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
| User |
|
||||||
|
| (Browser with Excalidraw React App) |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
| ^
|
||||||
|
| HTTP/S (API Calls) | HTTP/S (HTML/JS/CSS)
|
||||||
|
| WebSocket (Collaboration) |
|
||||||
|
v |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
| excalidraw-complete Go Binary |
|
||||||
|
| |
|
||||||
|
| +-------------------------+ +-----------------------------------+ |
|
||||||
|
| | HTTP Server (Chi) | | Socket.IO Server | |
|
||||||
|
| |-------------------------| |-----------------------------------| |
|
||||||
|
| | - API Routes (/api/v2) | <--> | - Connection Handling | |
|
||||||
|
| | - Firebase Routes | | - Room Management (Join/Leave) | |
|
||||||
|
| | - Static File Serving | | - Message Broadcasting | |
|
||||||
|
| +-------------------------+ +-----------------------------------+ |
|
||||||
|
| | ^ |
|
||||||
|
| | | |
|
||||||
|
| v | |
|
||||||
|
| +-------------------------------------------------------------------+ |
|
||||||
|
| | Core Logic & Modules | |
|
||||||
|
| |-------------------------------------------------------------------| |
|
||||||
|
| | | | |
|
||||||
|
| | +--------------------------+ | +-----------------------------+ | |
|
||||||
|
| | | Handlers (API Logic) | | | Embedded Frontend Assets | | |
|
||||||
|
| | +--------------------------+ | | (Patched Excalidraw UI) | | |
|
||||||
|
| | | | +-----------------------------+ | |
|
||||||
|
| | v | | |
|
||||||
|
| | +--------------------------+ | | |
|
||||||
|
| | | Storage Interface | | | |
|
||||||
|
| | | (core.DocumentStore) | | | |
|
||||||
|
| | +--------------------------+ | | |
|
||||||
|
| | | | | | | | |
|
||||||
|
| |----|------|--------|-------|--------------------------------------| |
|
||||||
|
| v v v v v |
|
||||||
|
| [S3] [SQLite] [FS] [Memory] (Storage Implementations) |
|
||||||
|
| |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
**架构说明:**
|
||||||
|
|
||||||
|
1. **Go主程序 (`main.go`)**: 作为应用的入口,它初始化并启动所有服务。
|
||||||
|
2. **HTTP服务器**: 使用 `Chi` 路由器来处理所有HTTP请求。这包括:
|
||||||
|
- **API服务**: 提供用于创建和获取文档的 RESTful API。
|
||||||
|
- **Firebase兼容层**: 模拟 Excalidraw 前端所需的 Firebase API。
|
||||||
|
- **静态文件服务**: 将嵌入的、经过修改的 Excalidraw 前端应用(HTML, JS, CSS等)提供给浏览器。
|
||||||
|
3. **Socket.IO服务器**: 独立处理 WebSocket 连接,负责所有实时协作功能,如同步绘图数据、光标位置等。
|
||||||
|
4. **存储层 (`stores`)**: 通过一个统一的 `core.DocumentStore` 接口,将数据存储逻辑抽象出来。可以根据环境变量在启动时选择不同的实现(S3、SQLite等)。
|
||||||
|
5. **嵌入式前端**: 前端 `Excalidraw` UI 作为一个 Git 子模块被包含在内。在构建阶段,它会被编译,并通过 Go 的 `embed` 特性直接嵌入到最终的二进制文件中。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 模块与服务说明 (Modules & Services)
|
||||||
|
|
||||||
|
### 4.1. 后端 (Backend)
|
||||||
|
|
||||||
|
#### 主应用 (`main.go`)
|
||||||
|
|
||||||
|
- **职责**: 应用的启动器和协调器。
|
||||||
|
- **核心逻辑**:
|
||||||
|
- 解析命令行参数 (`-listen`, `-loglevel`)。
|
||||||
|
- 根据环境变量初始化存储层 (`stores.GetStore()`)。
|
||||||
|
- 设置 `Chi` 路由器 (`setupRouter`),定义所有API路由。
|
||||||
|
- 设置 `Socket.IO` 服务器 (`setupSocketIO`),定义所有协作事件。
|
||||||
|
- 将 `/socket.io/` 路径的请求代理到 Socket.IO 服务器。
|
||||||
|
- **动态前端服务 (`handleUI`)**:
|
||||||
|
- 使用 Go 的 `embed` 包将编译后的前端文件打包进二进制文件。
|
||||||
|
- 在提供前端文件时,动态替换文件内容中的URL(如将 `firestore.googleapis.com` 替换为 `localhost:3002`),以重定向API请求到自身。
|
||||||
|
- 监听系统信号以实现优雅停机 (`waitForShutdown`)。
|
||||||
|
|
||||||
|
#### 核心模块 (`core/`)
|
||||||
|
|
||||||
|
- **`core/entity.go`**: 定义了项目中最核心的数据结构和接口。
|
||||||
|
- `Document`: 代表一个画板文档。
|
||||||
|
- `DocumentStore`: 一个接口,定义了所有存储后端必须实现的两个方法:`FindID` 和 `Create`。这是实现可插拔存储的关键。
|
||||||
|
|
||||||
|
#### 存储层 (`stores/`)
|
||||||
|
|
||||||
|
- **`stores/storage.go`**: 工厂模式的实现。`GetStore()` 函数根据环境变量 `STORAGE_TYPE` 的值,创建并返回一个具体的 `DocumentStore` 接口实例。
|
||||||
|
- **存储实现**:
|
||||||
|
- `stores/memory/`: 将文档保存在内存中,服务重启后数据丢失。
|
||||||
|
- `stores/filesystem/`: 将每个文档作为单独的文件保存在本地文件系统上。
|
||||||
|
- `stores/sqlite/`: 使用 SQLite 数据库来存储文档数据。
|
||||||
|
- `stores/aws/`: 使用 AWS S3 对象存储来保存文档。
|
||||||
|
|
||||||
|
#### HTTP处理器 (`handlers/`)
|
||||||
|
|
||||||
|
- **`handlers/api/documents/`**: 实现了自定义的文档API (`/api/v2`)。
|
||||||
|
- `HandleCreate`: 处理文档的创建请求。
|
||||||
|
- `HandleGet`: 处理文档的读取请求。
|
||||||
|
- **`handlers/api/firebase/`**: 一个内存实现的 Firebase API 模拟层。它拦截了原始 Excalidraw 前端对 Firebase 的 `batchGet` 和 `commit` 请求,并在内存中进行处理,以确保前端协作功能可以正常工作,而无需真实的 Firebase 后端。
|
||||||
|
|
||||||
|
### 4.2. 前端 (Frontend)
|
||||||
|
|
||||||
|
#### Excalidraw UI (`excalidraw/` submodule)
|
||||||
|
|
||||||
|
- 项目通过 Git Submodule 引入了官方的 `excalidraw` 仓库。这使得跟踪上游更新变得容易。
|
||||||
|
|
||||||
|
#### 前端补丁 (`frontend.patch`)
|
||||||
|
|
||||||
|
- 这是一个至关重要的文件。由于我们是自托管,需要修改 Excalidraw 前端的一些硬编码配置。该补丁文件在构建时应用,主要做了以下修改:
|
||||||
|
- **重定向API端点**: 将所有对 `excalidraw.com` 官方后端的API请求(如 `VITE_APP_BACKEND_V2_GET_URL`, `VITE_APP_WS_SERVER_URL`)重定向到自托管服务的地址(如 `http://localhost:3002`)。
|
||||||
|
- **修改Firebase配置**: 清空部分 Firebase 配置,因为后端已经提供了兼容层。
|
||||||
|
- **禁用追踪**: 设置 `VITE_APP_DISABLE_TRACKING=yes` 以禁用官方的数据追踪。
|
||||||
|
|
||||||
|
### 4.3. 前端架构分析 (Frontend Architecture)
|
||||||
|
|
||||||
|
`excalidraw` 自身是一个复杂的 `monorepo` 项目,其核心是可独立使用的 `@excalidraw/excalidraw` 包和一个完整的Web应用 `excalidraw-app`。我们的项目构建并嵌入的是 `excalidraw-app`。
|
||||||
|
|
||||||
|
#### `excalidraw-app` 项目地图 (Project Map)
|
||||||
|
|
||||||
|
以下是 `excalidraw/excalidraw-app` 目录的关键结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
excalidraw-app/
|
||||||
|
├── public/ # 静态资源,如 a-icons, fonts, manifest
|
||||||
|
├── components/ # 应用的主要React组件
|
||||||
|
│ ├── AppWelcomeScreen.tsx # 欢迎界面
|
||||||
|
│ ├── CollabButton.tsx # 协作按钮
|
||||||
|
│ ├── Library.tsx # 元素库UI
|
||||||
|
│ ├── Tooltip.tsx # 工具提示组件
|
||||||
|
│ └── ... # 其他UI组件
|
||||||
|
├── data/ # 数据处理与持久化相关的模块
|
||||||
|
│ ├── localForage.ts # IndexedDB的封装
|
||||||
|
│ ├── excalidraw.ts # Excalidraw核心库的导出与封装
|
||||||
|
│ └── ...
|
||||||
|
├── collab/ # 实时协作相关逻辑
|
||||||
|
│ ├── Collab.tsx # 协作功能的封装组件
|
||||||
|
│ ├── Portal.ts # 管理协作房间和用户
|
||||||
|
│ └── index.ts # 协作功能的初始化与管理
|
||||||
|
├── tests/ # 测试文件
|
||||||
|
├── App.tsx # 应用的根React组件,组织所有UI和逻辑
|
||||||
|
├── index.tsx # 应用的入口文件,将App组件渲染到DOM中
|
||||||
|
└── vite.config.mts # Vite构建配置文件
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 核心组件与逻辑
|
||||||
|
|
||||||
|
- **`App.tsx`**: 这是前端的"心脏"。它是一个巨大的组件,负责:
|
||||||
|
- 渲染主要的 Excalidraw 画布 (`<Excalidraw />` 组件)。
|
||||||
|
- 管理整个应用的状态(如图形元素、应用状态如当前工具、缩放等)。
|
||||||
|
- 处理用户输入事件。
|
||||||
|
- 初始化并集成协作模块 (`collab`)。
|
||||||
|
|
||||||
|
- **`components/`**: 包含了构成 Excalidraw 界面的所有可复用React组件,例如工具栏、菜单、对话框等。这使得UI层具有良好的模块化。
|
||||||
|
|
||||||
|
- **`collab/`**: 封装了所有与实时协作相关的功能。
|
||||||
|
- 它使用 `socket.io-client` 与后端的 Socket.IO 服务器建立连接。
|
||||||
|
- 负责发送和接收绘图数据、光标位置、用户加入/离开等事件。
|
||||||
|
- `Portal.ts` 是关键,它维护了当前协作会话的状态。
|
||||||
|
|
||||||
|
- **`data/`**: 负责数据的加载和保存。在自托管模式下,它通过 `fetch` API 与我们的Go后端进行通信,以保存和加载画板数据。原始的 Firebase 逻辑被我们后端的兼容层所替代。
|
||||||
|
|
||||||
|
**总结**: 前端是一个高度组件化的 React 应用。通过 `frontend.patch`,我们巧妙地将其数据和协作的"后端"从官方服务切换到了我们自己的一体化Go服务器上,实现了完全的自托管。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 构建与部署 (Build & Deployment)
|
||||||
|
|
||||||
|
|
||||||
|
`
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
# Excalidraw-Complete 改造计划 (BYOC - Bring Your Own Cloud Edition)
|
||||||
|
|
||||||
|
本文档旨在规划和跟踪将 `excalidraw-complete` 升级为一个支持用户认证、多画布管理,并具备前端直连云存储能力的协作平台所需要的开发任务。
|
||||||
|
|
||||||
|
**核心思想**: 后端负责 **"身份认证"** 与 **"默认存储"**,前端负责 **"存储适配与执行"**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 第一阶段:后端认证与用户体系基础
|
||||||
|
|
||||||
|
**目标**:为应用引入用户身份。这是所有个性化功能(如按用户存储画布)的基石。
|
||||||
|
|
||||||
|
### 后端 (Go)
|
||||||
|
- [x] **1.1.1**: 在 `go.mod` 中添加 `golang.org/x/oauth2` 依赖。
|
||||||
|
- [x] **1.1.2**: 创建新的 HTTP 处理器用于处理 OAuth2 流程。
|
||||||
|
- [x] **1.1.3**: 在 `main.go` 中添加认证路由:
|
||||||
|
- [x] `GET /auth/github/login`
|
||||||
|
- [x] `GET /auth/github/callback`
|
||||||
|
- [x] **1.1.4**: 实现从 GitHub API 获取用户信息的逻辑。
|
||||||
|
- [x] **1.1.5**: 引入 JWT 库 (e.g., `github.com/golang-jwt/jwt/v5`)。
|
||||||
|
- [x] **1.1.6**: 实现用户登录成功后生成和颁发 JWT 的逻辑。
|
||||||
|
- [x] **1.1.7**: 创建 `core/user.go` 定义 `User` 实体。
|
||||||
|
- [x] **1.1.8**: 创建一个可重用的 JWT 中间件,用于解析 Token 并将用户信息注入请求上下文。
|
||||||
|
|
||||||
|
### 前端 (React)
|
||||||
|
- [x] **1.2.1**: 在 UI 中AppWelcomeScreen中添加"使用 GitHub 登录"按钮。在excalidraw\excalidraw-app\components\AppMainMenu.tsx中添加"登录"按钮。
|
||||||
|
- [x] **1.2.2**: 添加api层,实现点击按钮后跳转到后端 `/auth/github/login` 的逻辑。
|
||||||
|
- [x] **1.2.3**: 创建一个用于处理登录回调的组件/页面,能从 URL 中解析出 JWT。
|
||||||
|
- [x] **1.2.4**: 将获取到的 JWT 安全地存储在 `localStorage` 或 `sessionStorage` 中。
|
||||||
|
- [x] **1.2.5**: 创建一个全局 API 请求封装(如 Axios 拦截器),为所有请求自动附加 `Authorization` 头。
|
||||||
|
- [x] **1.2.6**: 建立全局用户状态管理 (e.g., Jotai/Zustand),并在登录后更新 UI(如显示用户头像)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 第二阶段:前端存储抽象层与UI框架
|
||||||
|
|
||||||
|
**目标**:在前端建立一个灵活的存储适配器架构和相应的UI,为后续接入多种存储后端做好准备。
|
||||||
|
|
||||||
|
### 前端 (React)
|
||||||
|
- [x] **2.1.1**: 在 `src/data/` 目录下创建 `storage.ts` 文件。
|
||||||
|
- [x] **2.1.2**: 在 `storage.ts` 中定义 `IStorageAdapter` TypeScript 接口,包含 `listCanvases`, `loadCanvas`, `saveCanvas`, `createCanvas`, `deleteCanvas` 等方法。
|
||||||
|
- [x] **2.1.3**: 设计并实现一个新的"数据源配置"设置页面或模态框。
|
||||||
|
- [x] **2.1.4**: 在设置UI中,创建一个下拉菜单,包含未来的存储选项("默认后端", "Cloudflare KV", "Amazon S3")。
|
||||||
|
- [x] **2.1.5**: 根据下拉菜单的选择,动态渲染用于输入凭证的表单。
|
||||||
|
- [x] **2.1.6**: 在 UI 上添加明确的安全警告,告知用户密钥仅存储在浏览器会话中。
|
||||||
|
- [x] **2.1.7**: 创建全局状态来管理存储配置,将敏感凭证存储在 `sessionStorage`,非敏感配置存储在 `localStorage`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 第三阶段:实现默认后端KV作为第一个存储适配器
|
||||||
|
|
||||||
|
**目标**:将项目自身的 Go 后端作为第一个可用的存储选项,实现并验证前端的存储适配器架构。
|
||||||
|
|
||||||
|
### 后端 (Go)
|
||||||
|
- [ ] **3.1.1**: 升级 `core/entity.go` 中的 `Document` 结构,增加 `UserID`, `Name`, `CreatedAt` 等字段。
|
||||||
|
- [ ] **3.1.2**: 重构 `core.DocumentStore` 接口,增加 `ListByUser`, `Update`, `Delete` 等方法,并使所有方法都接受 `userID` 参数。
|
||||||
|
- [ ] **3.1.3**: 更新所有现有的存储实现 (`sqlite`, `filesystem` 等) 以匹配新的 `DocumentStore` 接口,并确保数据按用户ID隔离。
|
||||||
|
- [ ] **3.1.4**: 创建新的受 JWT 保护的 API 路由:
|
||||||
|
- [ ] `GET /api/v2/canvases`
|
||||||
|
- [ ] `POST /api/v2/canvases`
|
||||||
|
- [ ] `GET /api/v2/canvases/{id}`
|
||||||
|
- [ ] `PUT /api/v2/canvases/{id}`
|
||||||
|
- [ ] `DELETE /api/v2/canvases/{id}`
|
||||||
|
|
||||||
|
### 前端 (React)
|
||||||
|
- [ ] **3.2.1**: 创建 `src/data/BackendStorageAdapter.ts` 文件,并使其实现 `IStorageAdapter` 接口。
|
||||||
|
- [ ] **3.2.2**: 在该适配器内部,实现所有接口方法,使其通过 `fetch` 调用后端的 `/api/v2/canvases` 相关 API。
|
||||||
|
- [ ] **3.2.3**: 实现多画布管理的侧边栏 UI。
|
||||||
|
- [ ] **3.2.4**: 将侧边栏 UI 与 `BackendStorageAdapter` 连接,实现一个功能完整的、由后端驱动的多画布管理系统。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 第四阶段:实现Cloudflare KV客户端适配器
|
||||||
|
|
||||||
|
**目标**:实现第一个纯前端的存储选项,数据直接从浏览器发送到用户的Cloudflare KV。
|
||||||
|
|
||||||
|
### 前端 (React)
|
||||||
|
- [ ] **4.1.1**: 创建 `src/data/CloudflareKVAdapter.ts` 文件,并使其实现 `IStorageAdapter` 接口。
|
||||||
|
- [ ] **4.1.2**: 实现其构造函数,用于接收用户输入的 `accountId`, `namespaceId`, 和 `apiToken`。
|
||||||
|
- [ ] **4.1.3**: 在适配器内部,使用 `fetch` 实现对 Cloudflare KV 官方 API 的直接调用。
|
||||||
|
- [ ] **4.1.4**: 设计并在适配器中实现 KV 的键名(Key)管理策略。
|
||||||
|
- [ ] **4.1.5**: 在主应用逻辑中,当用户在设置中选择并配置了 Cloudflare KV 后,实例化并切换到 `CloudflareKVAdapter`。
|
||||||
|
- [ ] **4.1.6**: 验证所有画布操作(增删改查)都能在用户的 CF KV 上正确执行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 第五阶段:实现Amazon S3客户端适配器与最终打磨
|
||||||
|
|
||||||
|
**目标**:添加对S3的支持,并完善整个用户体验。
|
||||||
|
|
||||||
|
### 前端 (React)
|
||||||
|
- [ ] **5.1.1**: 在前端项目中添加 AWS SDK 依赖: `npm install @aws-sdk/client-s3`。
|
||||||
|
- [ ] **5.1.2**: 创建 `src/data/S3StorageAdapter.ts` 文件,并使其实现 `IStorageAdapter` 接口。
|
||||||
|
- [ ] **5.1.3**: 实现其构造函数,用于接收用户输入的 `accessKeyId`, `secretAccessKey`, `region`, `bucketName`。
|
||||||
|
- [ ] **5.1.4**: 在适配器内部,使用 `@aws-sdk/client-s3` 实现对 S3 对象的 `List`, `Get`, `Put`, `Delete` 操作。
|
||||||
|
- [ ] **5.1.5**: 设计并在适配器中实现 S3 的对象键(Key)管理策略。
|
||||||
|
- [ ] **5.1.6**: 在主应用逻辑中,当用户在设置中选择并配置了 S3 后,实例化并切换到 `S3StorageAdapter`。
|
||||||
|
|
||||||
|
### UX/UI 打磨
|
||||||
|
- [ ] **5.2.1**: 在每个数据源配置界面添加"测试连接"按钮,提供即时反馈。
|
||||||
|
- [ ] **5.2.2**: 完善在不同数据源之间切换时的用户体验,如提示保存未保存的更改。
|
||||||
|
|
||||||
|
- [ ] **5.2.3**: 在文档和UI中提供详细的指南,说明如何获取各种云服务的API密钥。
|
||||||
@@ -2,3 +2,9 @@ dist/
|
|||||||
frontend/
|
frontend/
|
||||||
!frontend/.keep
|
!frontend/.keep
|
||||||
excalidraw-complete
|
excalidraw-complete
|
||||||
|
*/node_modules
|
||||||
|
*/dist
|
||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
*/*.env
|
||||||
|
|||||||
+3
-3
@@ -1,3 +1,3 @@
|
|||||||
[submodule "excalidraw"]
|
[submodule "cloudflare-worker"]
|
||||||
path = excalidraw
|
path = cloudflare-worker
|
||||||
url = https://github.com/excalidraw/excalidraw.git
|
url = https://github.com/betterandbetterii/cf-kv.git
|
||||||
|
|||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
# Excalidraw-Complete 架构文档
|
||||||
|
|
||||||
|
本文档旨在详细阐述 `excalidraw-complete` 项目的系统架构、技术栈、模块设计和数据流,以便于开发者理解、维护和进行二次开发。
|
||||||
|
|
||||||
|
## 1. 概述 (Overview)
|
||||||
|
|
||||||
|
`excalidraw-complete` 是一个将优秀的开源白板工具 [Excalidraw](https://github.com/excalidraw/excalidraw) 进行整合与封装的自托管解决方案。其核心目标是简化 Excalidraw 的私有化部署流程,将前端UI、后端数据存储和实时协作服务打包成一个单一的、易于部署的Go二进制文件。
|
||||||
|
|
||||||
|
**核心特性:**
|
||||||
|
|
||||||
|
- **一体化部署**:将所有服务打包成单个可执行文件,无需复杂的依赖配置。
|
||||||
|
- **可插拔存储**:通过环境变量支持多种数据持久化方案,包括内存、本地文件系统、SQLite和AWS S3。
|
||||||
|
- **实时协作**:内置基于 Socket.IO 的实时协作服务器,允许多个用户同时在同一个画板上工作。
|
||||||
|
- **Firebase 兼容层**:提供一个内存实现的 Firebase API 兼容层,以满足 Excalidraw 前端对 Firebase 的部分依赖。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 技术栈 (Tech Stack)
|
||||||
|
|
||||||
|
项目采用了现代化的前后端技术栈。
|
||||||
|
|
||||||
|
### 后端 (Backend)
|
||||||
|
|
||||||
|
- **语言**: [Go](https://go.dev/) (v1.21+)
|
||||||
|
- **Web框架**: [Chi (v5)](https://github.com/go-chi/chi) - 一个轻量级、高性能的 Go HTTP 路由器。
|
||||||
|
- **实时通信**: [Socket.IO for Go](https://github.com/zishang520/socket.io/v2) - 实现了 Socket.IO 协议,用于实时协作。
|
||||||
|
- **数据库驱动**:
|
||||||
|
- [go-sqlite3](https://github.com/mattn/go-sqlite3) - 用于 SQLite 存储。
|
||||||
|
- [aws-sdk-go-v2](https://github.com/aws/aws-sdk-go-v2) - 用于与 AWS S3 交互。
|
||||||
|
- **日志**: [Logrus](https://github.com/sirupsen/logrus) - 结构化的日志记录库。
|
||||||
|
- **ID生成**: [ULID](https://github.com/oklog/ulid) - 用于生成唯一、可排序的文档ID。
|
||||||
|
|
||||||
|
### 前端 (Frontend)
|
||||||
|
|
||||||
|
- **核心**: [Excalidraw](https://github.com/excalidraw/excalidraw) (作为 Git Submodule)
|
||||||
|
- **框架**: [React](https://reactjs.org/)
|
||||||
|
- **构建工具**: [Vite](https://vitejs.dev/)
|
||||||
|
- **语言**: [TypeScript](https://www.typescriptlang.org/)
|
||||||
|
|
||||||
|
### 构建与部署 (Build & Deployment)
|
||||||
|
|
||||||
|
- **容器化**: [Docker](https://www.docker.com/) & `Dockerfile`
|
||||||
|
- **构建自动化**: Go Build Tools, npm/yarn
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 系统架构 (System Architecture)
|
||||||
|
|
||||||
|
`excalidraw-complete` 是一个典型的**单体架构 (Monolith)**,但内部逻辑分层清晰。
|
||||||
|
|
||||||
|
```
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
| User |
|
||||||
|
| (Browser with Excalidraw React App) |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
| ^
|
||||||
|
| HTTP/S (API Calls) | HTTP/S (HTML/JS/CSS)
|
||||||
|
| WebSocket (Collaboration) |
|
||||||
|
v |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
| excalidraw-complete Go Binary |
|
||||||
|
| |
|
||||||
|
| +-------------------------+ +-----------------------------------+ |
|
||||||
|
| | HTTP Server (Chi) | | Socket.IO Server | |
|
||||||
|
| |-------------------------| |-----------------------------------| |
|
||||||
|
| | - API Routes (/api/v2) | <--> | - Connection Handling | |
|
||||||
|
| | - Firebase Routes | | - Room Management (Join/Leave) | |
|
||||||
|
| | - Static File Serving | | - Message Broadcasting | |
|
||||||
|
| +-------------------------+ +-----------------------------------+ |
|
||||||
|
| | ^ |
|
||||||
|
| | | |
|
||||||
|
| v | |
|
||||||
|
| +-------------------------------------------------------------------+ |
|
||||||
|
| | Core Logic & Modules | |
|
||||||
|
| |-------------------------------------------------------------------| |
|
||||||
|
| | | | |
|
||||||
|
| | +--------------------------+ | +-----------------------------+ | |
|
||||||
|
| | | Handlers (API Logic) | | | Embedded Frontend Assets | | |
|
||||||
|
| | +--------------------------+ | | (Patched Excalidraw UI) | | |
|
||||||
|
| | | | +-----------------------------+ | |
|
||||||
|
| | v | | |
|
||||||
|
| | +--------------------------+ | | |
|
||||||
|
| | | Storage Interface | | | |
|
||||||
|
| | | (core.DocumentStore) | | | |
|
||||||
|
| | +--------------------------+ | | |
|
||||||
|
| | | | | | | | |
|
||||||
|
| |----|------|--------|-------|--------------------------------------| |
|
||||||
|
| v v v v v |
|
||||||
|
| [S3] [SQLite] [FS] [Memory] (Storage Implementations) |
|
||||||
|
| |
|
||||||
|
+-------------------------------------------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
**架构说明:**
|
||||||
|
|
||||||
|
1. **Go主程序 (`main.go`)**: 作为应用的入口,它初始化并启动所有服务。
|
||||||
|
2. **HTTP服务器**: 使用 `Chi` 路由器来处理所有HTTP请求。这包括:
|
||||||
|
- **API服务**: 提供用于创建和获取文档的 RESTful API。
|
||||||
|
- **Firebase兼容层**: 模拟 Excalidraw 前端所需的 Firebase API。
|
||||||
|
- **静态文件服务**: 将嵌入的、经过修改的 Excalidraw 前端应用(HTML, JS, CSS等)提供给浏览器。
|
||||||
|
3. **Socket.IO服务器**: 独立处理 WebSocket 连接,负责所有实时协作功能,如同步绘图数据、光标位置等。
|
||||||
|
4. **存储层 (`stores`)**: 通过一个统一的 `core.DocumentStore` 接口,将数据存储逻辑抽象出来。可以根据环境变量在启动时选择不同的实现(S3、SQLite等)。
|
||||||
|
5. **嵌入式前端**: 前端 `Excalidraw` UI 作为一个 Git 子模块被包含在内。在构建阶段,它会被编译,并通过 Go 的 `embed` 特性直接嵌入到最终的二进制文件中。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 模块与服务说明 (Modules & Services)
|
||||||
|
|
||||||
|
### 4.1. 后端 (Backend)
|
||||||
|
|
||||||
|
#### 主应用 (`main.go`)
|
||||||
|
|
||||||
|
- **职责**: 应用的启动器和协调器。
|
||||||
|
- **核心逻辑**:
|
||||||
|
- 解析命令行参数 (`-listen`, `-loglevel`)。
|
||||||
|
- 根据环境变量初始化存储层 (`stores.GetStore()`)。
|
||||||
|
- 设置 `Chi` 路由器 (`setupRouter`),定义所有API路由。
|
||||||
|
- 设置 `Socket.IO` 服务器 (`setupSocketIO`),定义所有协作事件。
|
||||||
|
- 将 `/socket.io/` 路径的请求代理到 Socket.IO 服务器。
|
||||||
|
- **动态前端服务 (`handleUI`)**:
|
||||||
|
- 使用 Go 的 `embed` 包将编译后的前端文件打包进二进制文件。
|
||||||
|
- 在提供前端文件时,动态替换文件内容中的URL(如将 `firestore.googleapis.com` 替换为 `localhost:3002`),以重定向API请求到自身。
|
||||||
|
- 监听系统信号以实现优雅停机 (`waitForShutdown`)。
|
||||||
|
|
||||||
|
#### 核心模块 (`core/`)
|
||||||
|
|
||||||
|
- **`core/entity.go`**: 定义了项目中最核心的数据结构和接口。
|
||||||
|
- `Document`: 代表一个画板文档。
|
||||||
|
- `DocumentStore`: 一个接口,定义了所有存储后端必须实现的两个方法:`FindID` 和 `Create`。这是实现可插拔存储的关键。
|
||||||
|
|
||||||
|
#### 存储层 (`stores/`)
|
||||||
|
|
||||||
|
- **`stores/storage.go`**: 工厂模式的实现。`GetStore()` 函数根据环境变量 `STORAGE_TYPE` 的值,创建并返回一个具体的 `DocumentStore` 接口实例。
|
||||||
|
- **存储实现**:
|
||||||
|
- `stores/memory/`: 将文档保存在内存中,服务重启后数据丢失。
|
||||||
|
- `stores/filesystem/`: 将每个文档作为单独的文件保存在本地文件系统上。
|
||||||
|
- `stores/sqlite/`: 使用 SQLite 数据库来存储文档数据。
|
||||||
|
- `stores/aws/`: 使用 AWS S3 对象存储来保存文档。
|
||||||
|
|
||||||
|
#### HTTP处理器 (`handlers/`)
|
||||||
|
|
||||||
|
- **`handlers/api/documents/`**: 实现了自定义的文档API (`/api/v2`)。
|
||||||
|
- `HandleCreate`: 处理文档的创建请求。
|
||||||
|
- `HandleGet`: 处理文档的读取请求。
|
||||||
|
- **`handlers/api/firebase/`**: 一个内存实现的 Firebase API 模拟层。它拦截了原始 Excalidraw 前端对 Firebase 的 `batchGet` 和 `commit` 请求,并在内存中进行处理,以确保前端协作功能可以正常工作,而无需真实的 Firebase 后端。
|
||||||
|
|
||||||
|
### 4.2. 前端 (Frontend)
|
||||||
|
|
||||||
|
#### Excalidraw UI (`excalidraw/` submodule)
|
||||||
|
|
||||||
|
- 项目通过 Git Submodule 引入了官方的 `excalidraw` 仓库。这使得跟踪上游更新变得容易。
|
||||||
|
|
||||||
|
#### 前端补丁 (`frontend.patch`)
|
||||||
|
|
||||||
|
- 这是一个至关重要的文件。由于我们是自托管,需要修改 Excalidraw 前端的一些硬编码配置。该补丁文件在构建时应用,主要做了以下修改:
|
||||||
|
- **重定向API端点**: 将所有对 `excalidraw.com` 官方后端的API请求(如 `VITE_APP_BACKEND_V2_GET_URL`, `VITE_APP_WS_SERVER_URL`)重定向到自托管服务的地址(如 `http://localhost:3002`)。
|
||||||
|
- **修改Firebase配置**: 清空部分 Firebase 配置,因为后端已经提供了兼容层。
|
||||||
|
- **禁用追踪**: 设置 `VITE_APP_DISABLE_TRACKING=yes` 以禁用官方的数据追踪。
|
||||||
|
|
||||||
|
### 4.3. 前端架构分析 (Frontend Architecture)
|
||||||
|
|
||||||
|
`excalidraw` 自身是一个复杂的 `monorepo` 项目,其核心是可独立使用的 `@excalidraw/excalidraw` 包和一个完整的Web应用 `excalidraw-app`。我们的项目构建并嵌入的是 `excalidraw-app`。
|
||||||
|
|
||||||
|
#### `excalidraw-app` 项目地图 (Project Map)
|
||||||
|
|
||||||
|
以下是 `excalidraw/excalidraw-app` 目录的关键结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
excalidraw-app/
|
||||||
|
├── public/ # 静态资源,如 a-icons, fonts, manifest
|
||||||
|
├── components/ # 应用的主要React组件
|
||||||
|
│ ├── AppWelcomeScreen.tsx # 欢迎界面
|
||||||
|
│ ├── CollabButton.tsx # 协作按钮
|
||||||
|
│ ├── Library.tsx # 元素库UI
|
||||||
|
│ ├── Tooltip.tsx # 工具提示组件
|
||||||
|
│ └── ... # 其他UI组件
|
||||||
|
├── data/ # 数据处理与持久化相关的模块
|
||||||
|
│ ├── localForage.ts # IndexedDB的封装
|
||||||
|
│ ├── excalidraw.ts # Excalidraw核心库的导出与封装
|
||||||
|
│ └── ...
|
||||||
|
├── collab/ # 实时协作相关逻辑
|
||||||
|
│ ├── Collab.tsx # 协作功能的封装组件
|
||||||
|
│ ├── Portal.ts # 管理协作房间和用户
|
||||||
|
│ └── index.ts # 协作功能的初始化与管理
|
||||||
|
├── tests/ # 测试文件
|
||||||
|
├── App.tsx # 应用的根React组件,组织所有UI和逻辑
|
||||||
|
├── index.tsx # 应用的入口文件,将App组件渲染到DOM中
|
||||||
|
└── vite.config.mts # Vite构建配置文件
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 核心组件与逻辑
|
||||||
|
|
||||||
|
- **`App.tsx`**: 这是前端的"心脏"。它是一个巨大的组件,负责:
|
||||||
|
- 渲染主要的 Excalidraw 画布 (`<Excalidraw />` 组件)。
|
||||||
|
- 管理整个应用的状态(如图形元素、应用状态如当前工具、缩放等)。
|
||||||
|
- 处理用户输入事件。
|
||||||
|
- 初始化并集成协作模块 (`collab`)。
|
||||||
|
|
||||||
|
- **`components/`**: 包含了构成 Excalidraw 界面的所有可复用React组件,例如工具栏、菜单、对话框等。这使得UI层具有良好的模块化。
|
||||||
|
|
||||||
|
- **`collab/`**: 封装了所有与实时协作相关的功能。
|
||||||
|
- 它使用 `socket.io-client` 与后端的 Socket.IO 服务器建立连接。
|
||||||
|
- 负责发送和接收绘图数据、光标位置、用户加入/离开等事件。
|
||||||
|
- `Portal.ts` 是关键,它维护了当前协作会话的状态。
|
||||||
|
|
||||||
|
- **`data/`**: 负责数据的加载和保存。在自托管模式下,它通过 `fetch` API 与我们的Go后端进行通信,以保存和加载画板数据。原始的 Firebase 逻辑被我们后端的兼容层所替代。
|
||||||
|
|
||||||
|
**总结**: 前端是一个高度组件化的 React 应用。通过 `frontend.patch`,我们巧妙地将其数据和协作的"后端"从官方服务切换到了我们自己的一体化Go服务器上,实现了完全的自托管。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 构建与部署 (Build & Deployment)
|
||||||
|
|
||||||
|
`
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Excalidraw-Complete 改造计划 (BYOC - Bring Your Own Cloud Edition)
|
||||||
|
|
||||||
|
本文档旨在规划和跟踪将 `excalidraw-complete` 升级为一个支持用户认证、多画布管理,并具备前端直连云存储能力的协作平台所需要的开发任务。
|
||||||
|
|
||||||
|
**核心思想**: 后端负责 **"身份认证"** 与 **"默认存储"**,前端负责 **"存储适配与执行"**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 第一阶段:后端认证与用户体系基础
|
||||||
|
|
||||||
|
**目标**:为应用引入用户身份。这是所有个性化功能(如按用户存储画布)的基石。
|
||||||
|
|
||||||
|
### 后端 (Go)
|
||||||
|
- [x] **1.1.1**: 在 `go.mod` 中添加 `golang.org/x/oauth2` 依赖。
|
||||||
|
- [x] **1.1.2**: 创建新的 HTTP 处理器用于处理 OAuth2 流程。
|
||||||
|
- [x] **1.1.3**: 在 `main.go` 中添加认证路由:
|
||||||
|
- [x] `GET /auth/github/login`
|
||||||
|
- [x] `GET /auth/github/callback`
|
||||||
|
- [x] **1.1.4**: 实现从 GitHub API 获取用户信息的逻辑。
|
||||||
|
- [x] **1.1.5**: 引入 JWT 库 (e.g., `github.com/golang-jwt/jwt/v5`)。
|
||||||
|
- [x] **1.1.6**: 实现用户登录成功后生成和颁发 JWT 的逻辑。
|
||||||
|
- [x] **1.1.7**: 创建 `core/user.go` 定义 `User` 实体。
|
||||||
|
- [x] **1.1.8**: 创建一个可重用的 JWT 中间件,用于解析 Token 并将用户信息注入请求上下文。
|
||||||
|
|
||||||
|
### 前端 (React)
|
||||||
|
- [x] **1.2.1**: 在 UI 中AppWelcomeScreen中添加"使用 GitHub 登录"按钮。在excalidraw\excalidraw-app\components\AppMainMenu.tsx中添加"登录"按钮。
|
||||||
|
- [x] **1.2.2**: 添加api层,实现点击按钮后跳转到后端 `/auth/github/login` 的逻辑。
|
||||||
|
- [x] **1.2.3**: 创建一个用于处理登录回调的组件/页面,能从 URL 中解析出 JWT。
|
||||||
|
- [x] **1.2.4**: 将获取到的 JWT 安全地存储在 `localStorage` 或 `sessionStorage` 中。
|
||||||
|
- [x] **1.2.5**: 创建一个全局 API 请求封装(如 Axios 拦截器),为所有请求自动附加 `Authorization` 头。
|
||||||
|
- [x] **1.2.6**: 建立全局用户状态管理 (e.g., Jotai/Zustand),并在登录后更新 UI(如显示用户头像)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 第二阶段:前端存储抽象层与UI框架
|
||||||
|
|
||||||
|
**目标**:在前端建立一个灵活的存储适配器架构和相应的UI,为后续接入多种存储后端做好准备。
|
||||||
|
|
||||||
|
### 前端 (React)
|
||||||
|
- [x] **2.1.1**: 在 `src/data/` 目录下创建 `storage.ts` 文件。
|
||||||
|
- [x] **2.1.2**: 在 `storage.ts` 中定义 `IStorageAdapter` TypeScript 接口,包含 `listCanvases`, `loadCanvas`, `saveCanvas`, `createCanvas`, `deleteCanvas` 等方法。
|
||||||
|
- [x] **2.1.3**: 设计并实现一个新的"数据源配置"设置页面或模态框。
|
||||||
|
- [x] **2.1.4**: 在设置UI中,创建一个下拉菜单,包含未来的存储选项("默认后端", "Cloudflare KV", "Amazon S3","IndexDB")。
|
||||||
|
- [x] **2.1.5**: 根据下拉菜单的选择,动态渲染用于输入凭证的表单。
|
||||||
|
- [x] **2.1.6**: 在 UI 上添加明确的安全警告,告知用户密钥仅存储在浏览器会话中。
|
||||||
|
- [x] **2.1.7**: 创建全局状态来管理存储配置,将敏感凭证存储在 `sessionStorage`,非敏感配置存储在 `localStorage`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 第三阶段:实现后端作为第一个KV存储适配器
|
||||||
|
|
||||||
|
**目标**:将项目自身的 Go 后端实现为一个简单的、面向用户的KV存储,作为第一个可用的存储选项。
|
||||||
|
|
||||||
|
### 后端 (Go) - KV API 设计
|
||||||
|
- **API理念**: 放弃复杂的RESTful设计,提供纯粹的KV操作接口,所有权与当前JWT用户绑定。
|
||||||
|
- **路由规划**:
|
||||||
|
- `GET /api/v2/kv`: 列出当前用户所有画布的元信息 (ID, Name, UpdatedAt)。
|
||||||
|
- `GET /api/v2/kv/{key}`: 获取单个画布的完整内容。
|
||||||
|
- `PUT /api/v2/kv/{key}`: 创建或更新一个画布。
|
||||||
|
- `DELETE /api/v2/kv/{key}`: 删除一个画布。
|
||||||
|
|
||||||
|
### 后端 (Go) - 执行步骤
|
||||||
|
- [x] **3.1.1**: 创建新的 `core/canvas.go` 文件,定义 `Canvas` 实体和 `CanvasStore` 接口。此举可避免与用于实时协作的旧 `Document` 模型冲突。
|
||||||
|
- [x] **3.1.2**: `Canvas` 实体将包含 `ID`, `UserID`, `Name`, `Data`, `CreatedAt`, `UpdatedAt` 字段。
|
||||||
|
- [x] **3.1.3**: `CanvasStore` 接口将定义 `List`, `Get`, `Save`, `Delete` 方法,所有方法都基于 `UserID` 操作以保证数据隔离。
|
||||||
|
- [x] **3.1.4**: 更新现有存储实现 (`sqlite`, `filesystem` 等) 以实现新的 `CanvasStore` 接口。
|
||||||
|
- [x] **3.1.5**: 创建新的 `handlers/api/kv/` 目录和处理器,实现上述KV API路由,并使用JWT中间件进行保护。
|
||||||
|
|
||||||
|
### 前端 (React)
|
||||||
|
- [x] **3.2.1**: 创建 `src/data/BackendStorageAdapter.ts` 文件,并使其实现 `IStorageAdapter` 接口。
|
||||||
|
- [x] **3.2.2**: 在该适配器内部,实现所有接口方法,使其通过 `fetch` 调用后端的 `/api/v2/kv` 相关 API。
|
||||||
|
- [x] **3.2.3**: 实现多画布管理的侧边栏 UI。
|
||||||
|
- [x] **3.2.4**: 将侧边栏 UI 与 `BackendStorageAdapter` 连接,实现一个功能完整的、由后端驱动的多画布管理系统。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 第四阶段:实现Cloudflare KV客户端适配器
|
||||||
|
|
||||||
|
**目标**:实现第一个纯前端的存储选项,数据直接从浏览器发送到用户的Cloudflare KV。
|
||||||
|
|
||||||
|
### 前端 (React)
|
||||||
|
- [ ] **4.1.1**: 创建 `src/data/CloudflareKVAdapter.ts` 文件,并使其实现 `IStorageAdapter` 接口。
|
||||||
|
- [ ] **4.1.2**: 实现其构造函数,用于接收用户输入的 `accountId`, `namespaceId`, 和 `apiToken`。
|
||||||
|
- [ ] **4.1.3**: 在适配器内部,使用 `fetch` 实现对 Cloudflare KV 官方 API 的直接调用。
|
||||||
|
- [ ] **4.1.4**: 设计并在适配器中实现 KV 的键名(Key)管理策略。
|
||||||
|
- [ ] **4.1.5**: 在主应用逻辑中,当用户在设置中选择并配置了 Cloudflare KV 后,实例化并切换到 `CloudflareKVAdapter`。
|
||||||
|
- [ ] **4.1.6**: 验证所有画布操作(增删改查)都能在用户的 CF KV 上正确执行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 第五阶段:实现Amazon S3客户端适配器与最终打磨
|
||||||
|
|
||||||
|
**目标**:添加对S3的支持,并完善整个用户体验。
|
||||||
|
|
||||||
|
### 前端 (React)
|
||||||
|
- [ ] **5.1.1**: 在前端项目中添加 AWS SDK 依赖: `npm install @aws-sdk/client-s3`。
|
||||||
|
- [ ] **5.1.2**: 创建 `src/data/S3StorageAdapter.ts` 文件,并使其实现 `IStorageAdapter` 接口。
|
||||||
|
- [ ] **5.1.3**: 实现其构造函数,用于接收用户输入的 `accessKeyId`, `secretAccessKey`, `region`, `bucketName`。
|
||||||
|
- [ ] **5.1.4**: 在适配器内部,使用 `@aws-sdk/client-s3` 实现对 S3 对象的 `List`, `Get`, `Put`, `Delete` 操作。
|
||||||
|
- [ ] **5.1.5**: 设计并在适配器中实现 S3 的对象键(Key)管理策略。
|
||||||
|
- [ ] **5.1.6**: 在主应用逻辑中,当用户在设置中选择并配置了 S3 后,实例化并切换到 `S3StorageAdapter`。
|
||||||
|
|
||||||
|
### UX/UI 打磨
|
||||||
|
- [ ] **5.2.1**: 在每个数据源配置界面添加"测试连接"按钮,提供即时反馈。
|
||||||
|
- [ ] **5.2.2**: 完善在不同数据源之间切换时的用户体验,如提示保存未保存的更改。
|
||||||
|
- [ ] **5.2.3**: 在文档和UI中提供详细的指南,说明如何获取各种云服务的API密钥。
|
||||||
Submodule
+1
Submodule cloudflare-worker added at 5ab2b18e91
@@ -0,0 +1,35 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
// Canvas represents the metadata and content of a user-saved drawing.
|
||||||
|
Canvas struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"-"` // Not exposed in JSON responses, used internally.
|
||||||
|
Name string `json:"name"`
|
||||||
|
Data []byte `json:"-"` // The full canvas data, not included in list views.
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanvasStore defines the persistence layer for user-owned canvases.
|
||||||
|
// All operations are scoped to a specific user.
|
||||||
|
CanvasStore interface {
|
||||||
|
// List returns metadata for all canvases owned by a user.
|
||||||
|
// The returned Canvas objects should not contain the `Data` field to keep the response light.
|
||||||
|
List(ctx context.Context, userID string) ([]*Canvas, error)
|
||||||
|
|
||||||
|
// Get returns a single canvas by its ID, ensuring it belongs to the user.
|
||||||
|
Get(ctx context.Context, userID, id string) (*Canvas, error)
|
||||||
|
|
||||||
|
// Save creates or updates a canvas for a user.
|
||||||
|
Save(ctx context.Context, canvas *Canvas) error
|
||||||
|
|
||||||
|
// Delete removes a canvas, ensuring it belongs to the user.
|
||||||
|
Delete(ctx context.Context, userID, id string) error
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type (
|
||||||
|
User struct {
|
||||||
|
ID uint `json:"id" gorm:"primarykey"`
|
||||||
|
GitHubID int64 `json:"githubId" gorm:"unique"`
|
||||||
|
Login string `json:"login"`
|
||||||
|
AvatarURL string `json:"avatarUrl"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
)
|
||||||
Binary file not shown.
@@ -1,14 +1,19 @@
|
|||||||
module excalidraw-complete
|
module excalidraw-complete
|
||||||
|
|
||||||
go 1.21
|
go 1.23.0
|
||||||
|
|
||||||
toolchain go1.22.1
|
toolchain go1.24.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.26.0
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.27.9
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.53.0
|
||||||
github.com/go-chi/chi/v5 v5.0.12
|
github.com/go-chi/chi/v5 v5.0.12
|
||||||
github.com/go-chi/cors v1.2.1
|
github.com/go-chi/cors v1.2.1
|
||||||
github.com/go-chi/render v1.0.3
|
github.com/go-chi/render v1.0.3
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22
|
||||||
github.com/oklog/ulid/v2 v2.1.0
|
github.com/oklog/ulid/v2 v2.1.0
|
||||||
|
github.com/sirupsen/logrus v1.9.3
|
||||||
github.com/zishang520/engine.io/v2 v2.0.6
|
github.com/zishang520/engine.io/v2 v2.0.6
|
||||||
github.com/zishang520/socket.io/v2 v2.0.5
|
github.com/zishang520/socket.io/v2 v2.0.5
|
||||||
)
|
)
|
||||||
@@ -16,9 +21,7 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/ajg/form v1.5.1 // indirect
|
github.com/ajg/form v1.5.1 // indirect
|
||||||
github.com/andybalholm/brotli v1.0.6 // indirect
|
github.com/andybalholm/brotli v1.0.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2 v1.26.0 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.1 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.1 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.27.9 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.9 // indirect
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.9 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.0 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.0 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.4 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.4 // indirect
|
||||||
@@ -29,23 +32,22 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.6 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.6 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.6 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.4 // indirect
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.4 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.53.0 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.20.3 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sso v1.20.3 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.3 // indirect
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.3 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.28.5 // indirect
|
github.com/aws/aws-sdk-go-v2/service/sts v1.28.5 // indirect
|
||||||
github.com/aws/smithy-go v1.20.1 // indirect
|
github.com/aws/smithy-go v1.20.1 // indirect
|
||||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
|
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||||
github.com/google/pprof v0.0.0-20230821062121-407c9e7a662f // indirect
|
github.com/google/pprof v0.0.0-20230821062121-407c9e7a662f // indirect
|
||||||
github.com/gookit/color v1.5.4 // indirect
|
github.com/gookit/color v1.5.4 // indirect
|
||||||
github.com/gorilla/websocket v1.5.0 // indirect
|
github.com/gorilla/websocket v1.5.0 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
github.com/joho/godotenv v1.5.1 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/onsi/ginkgo/v2 v2.12.0 // indirect
|
github.com/onsi/ginkgo/v2 v2.12.0 // indirect
|
||||||
github.com/quic-go/qpack v0.4.0 // indirect
|
github.com/quic-go/qpack v0.4.0 // indirect
|
||||||
github.com/quic-go/qtls-go1-20 v0.4.1 // indirect
|
github.com/quic-go/qtls-go1-20 v0.4.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.40.1 // indirect
|
github.com/quic-go/quic-go v0.40.1 // indirect
|
||||||
github.com/quic-go/webtransport-go v0.6.0 // indirect
|
github.com/quic-go/webtransport-go v0.6.0 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
|
||||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
|
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
|
||||||
@@ -56,6 +58,7 @@ require (
|
|||||||
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 // indirect
|
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 // indirect
|
||||||
golang.org/x/mod v0.12.0 // indirect
|
golang.org/x/mod v0.12.0 // indirect
|
||||||
golang.org/x/net v0.17.0 // indirect
|
golang.org/x/net v0.17.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/sys v0.15.0 // indirect
|
golang.org/x/sys v0.15.0 // indirect
|
||||||
golang.org/x/text v0.14.0 // indirect
|
golang.org/x/text v0.14.0 // indirect
|
||||||
golang.org/x/tools v0.12.1-0.20230815132531-74c255bcf846 // indirect
|
golang.org/x/tools v0.12.1-0.20230815132531-74c255bcf846 // indirect
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
|
|||||||
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
|
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
|
||||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
|
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||||
@@ -63,6 +65,8 @@ github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
|
|||||||
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
|
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
|
||||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
@@ -115,6 +119,8 @@ golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
|
|||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||||
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
||||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package kv
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"excalidraw-complete/core"
|
||||||
|
"excalidraw-complete/handlers/auth"
|
||||||
|
"excalidraw-complete/middleware"
|
||||||
|
"excalidraw-complete/stores"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HandleListCanvases(store stores.Store) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := r.Context().Value(middleware.ClaimsContextKey).(*auth.AppClaims)
|
||||||
|
if !ok {
|
||||||
|
render.Status(r, http.StatusUnauthorized)
|
||||||
|
render.JSON(w, r, map[string]string{"error": "User claims not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
canvases, err := store.List(r.Context(), claims.Subject)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithFields(logrus.Fields{
|
||||||
|
"error": err,
|
||||||
|
"userID": claims.Subject,
|
||||||
|
}).Error("Failed to list canvases")
|
||||||
|
render.Status(r, http.StatusInternalServerError)
|
||||||
|
render.JSON(w, r, map[string]string{"error": "Failed to list canvases"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If canvases is nil (e.g., user has no canvases), return an empty slice instead of null.
|
||||||
|
if canvases == nil {
|
||||||
|
canvases = []*core.Canvas{}
|
||||||
|
}
|
||||||
|
|
||||||
|
render.JSON(w, r, canvases)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGetCanvas(store stores.Store) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := r.Context().Value(middleware.ClaimsContextKey).(*auth.AppClaims)
|
||||||
|
if !ok {
|
||||||
|
render.Status(r, http.StatusUnauthorized)
|
||||||
|
render.JSON(w, r, map[string]string{"error": "User claims not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := chi.URLParam(r, "key")
|
||||||
|
if key == "" {
|
||||||
|
render.Status(r, http.StatusBadRequest)
|
||||||
|
render.JSON(w, r, map[string]string{"error": "Canvas key is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas, err := store.Get(r.Context(), claims.Subject, key)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithFields(logrus.Fields{
|
||||||
|
"error": err,
|
||||||
|
"userID": claims.Subject,
|
||||||
|
"key": key,
|
||||||
|
}).Warn("Failed to get canvas")
|
||||||
|
// This could be a not found error or a real server error.
|
||||||
|
// For simplicity, we'll return 404, but in a real app, you might want to distinguish.
|
||||||
|
render.Status(r, http.StatusNotFound)
|
||||||
|
render.JSON(w, r, map[string]string{"error": "Canvas not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The canvas data is returned as raw bytes.
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write(canvas.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleSaveCanvas(store stores.Store) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := r.Context().Value(middleware.ClaimsContextKey).(*auth.AppClaims)
|
||||||
|
if !ok {
|
||||||
|
render.Status(r, http.StatusUnauthorized)
|
||||||
|
render.JSON(w, r, map[string]string{"error": "User claims not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := chi.URLParam(r, "key")
|
||||||
|
if key == "" {
|
||||||
|
render.Status(r, http.StatusBadRequest)
|
||||||
|
render.JSON(w, r, map[string]string{"error": "Canvas key is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
logrus.WithFields(logrus.Fields{
|
||||||
|
"error": err,
|
||||||
|
"key": key,
|
||||||
|
}).Error("Failed to read request body")
|
||||||
|
render.Status(r, http.StatusInternalServerError)
|
||||||
|
render.JSON(w, r, map[string]string{"error": "Failed to read request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
// For simplicity, we use the key as the name. A more advanced implementation
|
||||||
|
// might parse a name from the body or have a separate field.
|
||||||
|
var canvasData struct {
|
||||||
|
AppState struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"appState"`
|
||||||
|
}
|
||||||
|
// We make a copy of the body because json.Unmarshal will consume the reader.
|
||||||
|
bodyCopy := make([]byte, len(body))
|
||||||
|
copy(bodyCopy, body)
|
||||||
|
|
||||||
|
canvasName := key // Default to key
|
||||||
|
if err := json.Unmarshal(bodyCopy, &canvasData); err == nil && canvasData.AppState.Name != "" {
|
||||||
|
canvasName = canvasData.AppState.Name
|
||||||
|
}
|
||||||
|
canvas := &core.Canvas{
|
||||||
|
ID: key,
|
||||||
|
UserID: claims.Subject,
|
||||||
|
Name: canvasName,
|
||||||
|
Data: body,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Save(r.Context(), canvas); err != nil {
|
||||||
|
logrus.WithFields(logrus.Fields{
|
||||||
|
"error": err,
|
||||||
|
"userID": claims.Subject,
|
||||||
|
"key": key,
|
||||||
|
}).Error("Failed to save canvas")
|
||||||
|
render.Status(r, http.StatusInternalServerError)
|
||||||
|
render.JSON(w, r, map[string]string{"error": "Failed to save canvas"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
render.Status(r, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleDeleteCanvas(store stores.Store) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := r.Context().Value(middleware.ClaimsContextKey).(*auth.AppClaims)
|
||||||
|
if !ok {
|
||||||
|
render.Status(r, http.StatusUnauthorized)
|
||||||
|
render.JSON(w, r, map[string]string{"error": "User claims not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := chi.URLParam(r, "key")
|
||||||
|
if key == "" {
|
||||||
|
render.Status(r, http.StatusBadRequest)
|
||||||
|
render.JSON(w, r, map[string]string{"error": "Canvas key is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Delete(r.Context(), claims.Subject, key); err != nil {
|
||||||
|
logrus.WithFields(logrus.Fields{
|
||||||
|
"error": err,
|
||||||
|
"userID": claims.Subject,
|
||||||
|
"key": key,
|
||||||
|
}).Error("Failed to delete canvas")
|
||||||
|
render.Status(r, http.StatusInternalServerError)
|
||||||
|
render.JSON(w, r, map[string]string{"error": "Failed to delete canvas"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
render.Status(r, http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"excalidraw-complete/handlers/auth"
|
||||||
|
"excalidraw-complete/middleware"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
openaiAPIKey string
|
||||||
|
openaiBaseURL string
|
||||||
|
)
|
||||||
|
|
||||||
|
func Init() {
|
||||||
|
openaiAPIKey = os.Getenv("OPENAI_API_KEY")
|
||||||
|
openaiBaseURL = os.Getenv("OPENAI_BASE_URL")
|
||||||
|
if openaiBaseURL == "" {
|
||||||
|
openaiBaseURL = "https://api.openai.com" // Default value
|
||||||
|
}
|
||||||
|
if openaiAPIKey == "" {
|
||||||
|
log.Println("WARNING: OPENAI_API_KEY environment variable not set. OpenAI proxy will not work.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structures for OpenAI compatibility
|
||||||
|
|
||||||
|
type LiteralType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
LiteralTypeText LiteralType = "text"
|
||||||
|
LiteralTypeImageURL LiteralType = "image_url"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserTextContentPart corresponds to a part of a multi-part message with text.
|
||||||
|
type UserTextContentPart struct {
|
||||||
|
Type LiteralType `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageURL details the URL and detail level of an image.
|
||||||
|
type ImageURL struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserImageContentPart corresponds to a part of a multi-part message with an image.
|
||||||
|
type UserImageContentPart struct {
|
||||||
|
Type LiteralType `json:"type"`
|
||||||
|
ImageURL ImageURL `json:"image_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserContentPart struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserContext struct {
|
||||||
|
UserID int `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatMessage struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content any `json:"content"` // Can be string or a slice of UserTextContentPart/UserImageContentPart
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatCompletionRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Messages []ChatMessage `json:"messages"`
|
||||||
|
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||||
|
Stream *bool `json:"stream"`
|
||||||
|
// Other fields like temperature, max_tokens etc. are ignored for this mock
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatCompletionChoice struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Message ChatMessage `json:"message"`
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Usage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatCompletionResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Choices []ChatCompletionChoice `json:"choices"`
|
||||||
|
Usage Usage `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlusherWriter is a helper to ensure that data is flushed to the client for streaming
|
||||||
|
type FlusherWriter struct {
|
||||||
|
w http.ResponseWriter
|
||||||
|
f http.Flusher
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fw *FlusherWriter) Write(p []byte) (int, error) {
|
||||||
|
n, err := fw.w.Write(p)
|
||||||
|
if fw.f != nil {
|
||||||
|
fw.f.Flush()
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleChatCompletion() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Verify user is authenticated
|
||||||
|
_, ok := r.Context().Value(middleware.ClaimsContextKey).(*auth.AppClaims)
|
||||||
|
if !ok {
|
||||||
|
render.Status(r, http.StatusUnauthorized)
|
||||||
|
render.JSON(w, r, map[string]string{"error": "User claims not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if openaiAPIKey == "" {
|
||||||
|
render.Status(r, http.StatusInternalServerError)
|
||||||
|
render.JSON(w, r, map[string]string{"error": "OpenAI API key is not configured on the server"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the original request body
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
render.Status(r, http.StatusInternalServerError)
|
||||||
|
render.JSON(w, r, map[string]string{"error": "Failed to read request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
// Unmarshal to check if it's a streaming request
|
||||||
|
var req ChatCompletionRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
render.Status(r, http.StatusBadRequest)
|
||||||
|
render.JSON(w, r, map[string]string{"error": "Invalid JSON in request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the proxy request to OpenAI
|
||||||
|
proxyURL := openaiBaseURL + "/v1/chat/completions"
|
||||||
|
proxyReq, err := http.NewRequestWithContext(r.Context(), "POST", proxyURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
render.Status(r, http.StatusInternalServerError)
|
||||||
|
render.JSON(w, r, map[string]string{"error": "Failed to create proxy request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set necessary headers
|
||||||
|
proxyReq.Header.Set("Authorization", "Bearer "+openaiAPIKey)
|
||||||
|
proxyReq.Header.Set("Content-Type", "application/json")
|
||||||
|
proxyReq.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
// Send the request to OpenAI
|
||||||
|
client := &http.Client{Timeout: 5 * time.Minute}
|
||||||
|
resp, err := client.Do(proxyReq)
|
||||||
|
if err != nil {
|
||||||
|
render.Status(r, http.StatusBadGateway)
|
||||||
|
render.JSON(w, r, map[string]string{"error": "Failed to communicate with OpenAI API"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Handle the response based on whether it's a stream or not
|
||||||
|
if req.Stream != nil && *req.Stream {
|
||||||
|
// Streaming response
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy headers from OpenAI response to our response
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
|
||||||
|
fw := &FlusherWriter{w: w, f: flusher}
|
||||||
|
if _, err := io.Copy(fw, resp.Body); err != nil {
|
||||||
|
// Log error, but the response is likely already sent/broken.
|
||||||
|
log.Printf("Error streaming response from OpenAI: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-streaming response
|
||||||
|
// Copy headers from OpenAI response
|
||||||
|
for key, values := range resp.Header {
|
||||||
|
for _, value := range values {
|
||||||
|
w.Header().Add(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
io.Copy(w, resp.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"excalidraw-complete/core"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
"golang.org/x/oauth2/github"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
githubOauthConfig *oauth2.Config
|
||||||
|
jwtSecret []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
const oauthStateString = "random"
|
||||||
|
|
||||||
|
// AppClaims represents the custom claims for the JWT.
|
||||||
|
type AppClaims struct {
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
Login string `json:"login"`
|
||||||
|
AvatarURL string `json:"avatarUrl"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Init() {
|
||||||
|
githubOauthConfig = &oauth2.Config{
|
||||||
|
ClientID: os.Getenv("GITHUB_CLIENT_ID"),
|
||||||
|
ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
|
||||||
|
RedirectURL: os.Getenv("GITHUB_REDIRECT_URL"),
|
||||||
|
Scopes: []string{"read:user", "user:email"},
|
||||||
|
Endpoint: github.Endpoint,
|
||||||
|
}
|
||||||
|
jwtSecret = []byte(os.Getenv("JWT_SECRET"))
|
||||||
|
|
||||||
|
if githubOauthConfig.ClientID == "" || githubOauthConfig.ClientSecret == "" {
|
||||||
|
logrus.Warn("GitHub OAuth credentials are not set. Authentication routes will not work.")
|
||||||
|
}
|
||||||
|
if len(jwtSecret) == 0 {
|
||||||
|
logrus.Warn("JWT_SECRET is not set. Authentication routes will not work.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateStateOauthCookie(w http.ResponseWriter) string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
rand.Read(b)
|
||||||
|
state := base64.URLEncoding.EncodeToString(b)
|
||||||
|
cookie := &http.Cookie{
|
||||||
|
Name: "oauthstate",
|
||||||
|
Value: state,
|
||||||
|
Expires: time.Now().Add(10 * time.Minute),
|
||||||
|
HttpOnly: true,
|
||||||
|
}
|
||||||
|
http.SetCookie(w, cookie)
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGitHubLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if githubOauthConfig.ClientID == "" {
|
||||||
|
http.Error(w, "GitHub OAuth is not configured", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state := generateStateOauthCookie(w)
|
||||||
|
url := githubOauthConfig.AuthCodeURL(state)
|
||||||
|
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGitHubCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if githubOauthConfig.ClientID == "" {
|
||||||
|
http.Error(w, "GitHub OAuth is not configured", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
oauthState, _ := r.Cookie("oauthstate")
|
||||||
|
if r.FormValue("state") != oauthState.Value {
|
||||||
|
logrus.Error("invalid oauth github state")
|
||||||
|
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := githubOauthConfig.Exchange(context.Background(), r.FormValue("code"))
|
||||||
|
if err != nil {
|
||||||
|
logrus.Errorf("failed to exchange token: %s", err.Error())
|
||||||
|
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := githubOauthConfig.Client(context.Background(), token)
|
||||||
|
resp, err := client.Get("https://api.github.com/user")
|
||||||
|
if err != nil {
|
||||||
|
logrus.Errorf("failed to get user from github: %s", err.Error())
|
||||||
|
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
logrus.Errorf("failed to read github response body: %s", err.Error())
|
||||||
|
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var githubUser struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Login string `json:"login"`
|
||||||
|
AvatarURL string `json:"avatar_url"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &githubUser); err != nil {
|
||||||
|
logrus.Errorf("failed to unmarshal github user: %s", err.Error())
|
||||||
|
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now we don't have a user database, so we create a user object on the fly.
|
||||||
|
// In phase 3, we will save/get the user from the database here.
|
||||||
|
user := &core.User{
|
||||||
|
GitHubID: githubUser.ID,
|
||||||
|
Login: githubUser.Login,
|
||||||
|
AvatarURL: githubUser.AvatarURL,
|
||||||
|
Name: githubUser.Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
jwtToken, err := createJWT(user)
|
||||||
|
if err != nil {
|
||||||
|
logrus.Errorf("failed to create JWT: %s", err.Error())
|
||||||
|
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to frontend with token
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/?token=%s", jwtToken), http.StatusTemporaryRedirect)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createJWT(user *core.User) (string, error) {
|
||||||
|
claims := AppClaims{
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: fmt.Sprintf("%d", user.GitHubID),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 7)), // 1 week
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
},
|
||||||
|
Login: user.Login,
|
||||||
|
AvatarURL: user.AvatarURL,
|
||||||
|
Name: user.Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString(jwtSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseJWT(tokenString string) (*AppClaims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &AppClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
|
}
|
||||||
|
return jwtSecret, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims, ok := token.Claims.(*AppClaims); ok && token.Valid {
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("invalid token")
|
||||||
|
}
|
||||||
@@ -3,9 +3,12 @@ package main
|
|||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"excalidraw-complete/core"
|
|
||||||
"excalidraw-complete/handlers/api/documents"
|
"excalidraw-complete/handlers/api/documents"
|
||||||
"excalidraw-complete/handlers/api/firebase"
|
"excalidraw-complete/handlers/api/firebase"
|
||||||
|
"excalidraw-complete/handlers/api/kv"
|
||||||
|
"excalidraw-complete/handlers/api/openai"
|
||||||
|
"excalidraw-complete/handlers/auth"
|
||||||
|
authMiddleware "excalidraw-complete/middleware"
|
||||||
"excalidraw-complete/stores"
|
"excalidraw-complete/stores"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -17,11 +20,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/zishang520/engine.io/v2/types"
|
"github.com/zishang520/engine.io/v2/types"
|
||||||
"github.com/zishang520/engine.io/v2/utils"
|
"github.com/zishang520/engine.io/v2/utils"
|
||||||
socketio "github.com/zishang520/socket.io/v2/socket"
|
socketio "github.com/zishang520/socket.io/v2/socket"
|
||||||
@@ -103,7 +106,7 @@ func handleUI() http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupRouter(documentStore core.DocumentStore) *chi.Mux {
|
func setupRouter(store stores.Store) *chi.Mux {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
|
|
||||||
@@ -121,13 +124,37 @@ func setupRouter(documentStore core.DocumentStore) *chi.Mux {
|
|||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/api/v2", func(r chi.Router) {
|
r.Route("/api/v2", func(r chi.Router) {
|
||||||
r.Post("/post/", documents.HandleCreate(documentStore))
|
// Route for canvases, protected by JWT auth
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(authMiddleware.AuthJWT)
|
||||||
|
r.Route("/kv", func(r chi.Router) {
|
||||||
|
r.Get("/", kv.HandleListCanvases(store))
|
||||||
|
r.Route("/{key}", func(r chi.Router) {
|
||||||
|
r.Get("/", kv.HandleGetCanvas(store))
|
||||||
|
r.Put("/", kv.HandleSaveCanvas(store))
|
||||||
|
r.Delete("/", kv.HandleDeleteCanvas(store))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
r.Route("/chat", func(r chi.Router) {
|
||||||
|
r.Post("/completions", openai.HandleChatCompletion())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Old routes for anonymous document sharing
|
||||||
|
r.Post("/post/", documents.HandleCreate(store))
|
||||||
r.Route("/{id}", func(r chi.Router) {
|
r.Route("/{id}", func(r chi.Router) {
|
||||||
r.Get("/", documents.HandleGet(documentStore))
|
r.Get("/", documents.HandleGet(store))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
r.Route("/auth/github", func(r chi.Router) {
|
||||||
|
r.Get("/login", auth.HandleGitHubLogin)
|
||||||
|
r.Get("/callback", auth.HandleGitHubCallback)
|
||||||
|
})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupSocketIO() *socketio.Server {
|
func setupSocketIO() *socketio.Server {
|
||||||
opts := socketio.DefaultServerOptions()
|
opts := socketio.DefaultServerOptions()
|
||||||
opts.SetMaxHttpBufferSize(5000000)
|
opts.SetMaxHttpBufferSize(5000000)
|
||||||
@@ -242,39 +269,41 @@ func waitForShutdown(ioo *socketio.Server) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Define a log level flag
|
// Load .env file
|
||||||
logLevel := flag.String("loglevel", "info", "Set the logging level: debug, info, warn, error, fatal, panic")
|
if err := godotenv.Load(); err != nil {
|
||||||
listenAddr := flag.String("listen", ":3002", "Set the server listen address")
|
logrus.Info("No .env file found")
|
||||||
|
}
|
||||||
|
|
||||||
|
listenAddress := flag.String("listen", ":3002", "The address to listen on.")
|
||||||
|
logLevel := flag.String("loglevel", "info", "The log level (debug, info, warn, error).")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// Set the log level
|
|
||||||
level, err := logrus.ParseLevel(*logLevel)
|
level, err := logrus.ParseLevel(*logLevel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Invalid log level: %v\n", err)
|
logrus.Fatalf("Invalid log level: %v", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
logrus.SetLevel(level)
|
logrus.SetLevel(level)
|
||||||
|
logrus.SetFormatter(&logrus.TextFormatter{
|
||||||
documentStore := stores.GetStore() // Make sure this is well-defined in your "stores" package
|
FullTimestamp: true,
|
||||||
r := setupRouter(documentStore)
|
|
||||||
ioo := setupSocketIO()
|
|
||||||
r.Handle("/socket.io/", ioo.ServeHandler(nil))
|
|
||||||
r.Get("/ping", func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
_, err := w.Write([]byte("pong"))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
auth.Init()
|
||||||
|
openai.Init()
|
||||||
|
store := stores.GetStore()
|
||||||
|
|
||||||
|
r := setupRouter(store)
|
||||||
|
|
||||||
|
ioo := setupSocketIO()
|
||||||
|
r.Mount("/socket.io/", ioo.ServeHandler(nil))
|
||||||
r.Mount("/", handleUI())
|
r.Mount("/", handleUI())
|
||||||
|
|
||||||
logrus.WithField("addr", *listenAddr).Info("starting server")
|
logrus.WithField("addr", *listenAddress).Info("starting server")
|
||||||
go func() {
|
go func() {
|
||||||
if err := http.ListenAndServe(*listenAddr, r); err != nil {
|
if err := http.ListenAndServe(*listenAddress, r); err != nil {
|
||||||
logrus.WithField("event", "start server").Fatal(err)
|
logrus.WithField("event", "start server").Fatal(err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
logrus.Debug("Server is running in the background")
|
logrus.Debug("Server is running in the background")
|
||||||
waitForShutdown(ioo)
|
waitForShutdown(ioo)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"excalidraw-complete/handlers/auth"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const ClaimsContextKey = contextKey("claims")
|
||||||
|
|
||||||
|
func AuthJWT(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
render.Status(r, http.StatusUnauthorized)
|
||||||
|
render.JSON(w, r, map[string]string{"error": "Authorization header is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(authHeader, " ")
|
||||||
|
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
||||||
|
render.Status(r, http.StatusUnauthorized)
|
||||||
|
render.JSON(w, r, map[string]string{"error": "Authorization header format must be Bearer {token}"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenString := parts[1]
|
||||||
|
claims, err := auth.ParseJWT(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
render.Status(r, http.StatusUnauthorized)
|
||||||
|
render.JSON(w, r, map[string]string{"error": "Invalid token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), ClaimsContextKey, claims)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package aws
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"excalidraw-complete/core"
|
|
||||||
"fmt"
|
|
||||||
"io/ioutil"
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
|
||||||
"github.com/aws/aws-sdk-go-v2/config"
|
|
||||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
|
||||||
"github.com/oklog/ulid/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
type documentStore struct {
|
|
||||||
s3Client *s3.Client
|
|
||||||
bucket string // Name of the S3 bucket
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDocumentStore(bucketName string) core.DocumentStore {
|
|
||||||
cfg, err := config.LoadDefaultConfig(context.TODO())
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("unable to load SDK config, %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s3Client := s3.NewFromConfig(cfg)
|
|
||||||
|
|
||||||
return &documentStore{
|
|
||||||
s3Client: s3Client,
|
|
||||||
bucket: bucketName,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *documentStore) FindID(ctx context.Context, id string) (*core.Document, error) {
|
|
||||||
resp, err := s.s3Client.GetObject(ctx, &s3.GetObjectInput{
|
|
||||||
Bucket: aws.String(s.bucket),
|
|
||||||
Key: aws.String(id),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get document with id %s: %v", id, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
data, err := ioutil.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read document data: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
document := core.Document{
|
|
||||||
Data: *bytes.NewBuffer(data),
|
|
||||||
}
|
|
||||||
|
|
||||||
return &document, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *documentStore) Create(ctx context.Context, document *core.Document) (string, error) {
|
|
||||||
id := ulid.Make().String()
|
|
||||||
|
|
||||||
_, err := s.s3Client.PutObject(ctx, &s3.PutObjectInput{
|
|
||||||
Bucket: aws.String(s.bucket),
|
|
||||||
Key: aws.String(id),
|
|
||||||
Body: bytes.NewReader(document.Data.Bytes()),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to upload document: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package aws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"excalidraw-complete/core"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/config"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||||
|
"github.com/oklog/ulid/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type s3Store struct {
|
||||||
|
s3Client *s3.Client
|
||||||
|
bucket string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore creates a new S3-based store.
|
||||||
|
func NewStore(bucketName string) *s3Store {
|
||||||
|
cfg, err := config.LoadDefaultConfig(context.TODO())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("unable to load SDK config, %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s3Client := s3.NewFromConfig(cfg)
|
||||||
|
|
||||||
|
return &s3Store{
|
||||||
|
s3Client: s3Client,
|
||||||
|
bucket: bucketName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocumentStore implementation for anonymous sharing
|
||||||
|
func (s *s3Store) FindID(ctx context.Context, id string) (*core.Document, error) {
|
||||||
|
resp, err := s.s3Client.GetObject(ctx, &s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(s.bucket),
|
||||||
|
Key: aws.String(id),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get document with id %s: %v", id, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
data, err := ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read document data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
document := core.Document{
|
||||||
|
Data: *bytes.NewBuffer(data),
|
||||||
|
}
|
||||||
|
|
||||||
|
return &document, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Store) Create(ctx context.Context, document *core.Document) (string, error) {
|
||||||
|
id := ulid.Make().String()
|
||||||
|
|
||||||
|
_, err := s.s3Client.PutObject(ctx, &s3.PutObjectInput{
|
||||||
|
Bucket: aws.String(s.bucket),
|
||||||
|
Key: aws.String(id),
|
||||||
|
Body: bytes.NewReader(document.Data.Bytes()),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to upload document: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanvasStore implementation for user-owned canvases
|
||||||
|
func (s *s3Store) getCanvasKey(userID, canvasID string) string {
|
||||||
|
return path.Join(userID, canvasID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Store) List(ctx context.Context, userID string) ([]*core.Canvas, error) {
|
||||||
|
prefix := userID + "/"
|
||||||
|
output, err := s.s3Client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
|
||||||
|
Bucket: aws.String(s.bucket),
|
||||||
|
Prefix: aws.String(prefix),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list canvases for user %s: %v", userID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
canvases := make([]*core.Canvas, 0, len(output.Contents))
|
||||||
|
for _, object := range output.Contents {
|
||||||
|
canvasID := path.Base(*object.Key)
|
||||||
|
canvas := &core.Canvas{
|
||||||
|
ID: canvasID,
|
||||||
|
UserID: userID,
|
||||||
|
Name: canvasID, // S3 doesn't have a native 'name' field, using ID.
|
||||||
|
UpdatedAt: *object.LastModified,
|
||||||
|
}
|
||||||
|
canvases = append(canvases, canvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
return canvases, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Store) Get(ctx context.Context, userID, id string) (*core.Canvas, error) {
|
||||||
|
key := s.getCanvasKey(userID, id)
|
||||||
|
resp, err := s.s3Client.GetObject(ctx, &s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(s.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// A specific check for NoSuchKey can be useful here.
|
||||||
|
if bytes.Contains([]byte(err.Error()), []byte("NoSuchKey")) {
|
||||||
|
return nil, fmt.Errorf("canvas not found")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to get canvas %s: %v", id, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
data, err := ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read canvas data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas := &core.Canvas{
|
||||||
|
ID: id,
|
||||||
|
UserID: userID,
|
||||||
|
Name: id,
|
||||||
|
Data: data,
|
||||||
|
UpdatedAt: *resp.LastModified,
|
||||||
|
}
|
||||||
|
|
||||||
|
return canvas, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Store) Save(ctx context.Context, canvas *core.Canvas) error {
|
||||||
|
key := s.getCanvasKey(canvas.UserID, canvas.ID)
|
||||||
|
_, err := s.s3Client.PutObject(ctx, &s3.PutObjectInput{
|
||||||
|
Bucket: aws.String(s.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
Body: bytes.NewReader(canvas.Data),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to save canvas %s: %v", canvas.ID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Store) Delete(ctx context.Context, userID, id string) error {
|
||||||
|
key := s.getCanvasKey(userID, id)
|
||||||
|
_, err := s.s3Client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||||
|
Bucket: aws.String(s.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete canvas %s: %v", id, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package filesystem
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"excalidraw-complete/core"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/oklog/ulid/v2"
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
type documentStore struct {
|
|
||||||
basePath string // Directory where documents are stored.
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDocumentStore(basePath string) core.DocumentStore {
|
|
||||||
if err := os.MkdirAll(basePath, 0755); err != nil {
|
|
||||||
log.Fatalf("failed to create base directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &documentStore{basePath: basePath}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *documentStore) FindID(ctx context.Context, id string) (*core.Document, error) {
|
|
||||||
filePath := filepath.Join(s.basePath, id)
|
|
||||||
log := logrus.WithField("document_id", id)
|
|
||||||
|
|
||||||
log.WithField("file_path", filePath).Info("Retrieving document by ID")
|
|
||||||
data, err := os.ReadFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
log.WithField("error", "document not found").Warn("Document with specified ID not found")
|
|
||||||
return nil, fmt.Errorf("document with id %s not found", id)
|
|
||||||
}
|
|
||||||
log.WithField("error", err).Error("Failed to retrieve document")
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
document := core.Document{
|
|
||||||
Data: *bytes.NewBuffer(data),
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("Document retrieved successfully")
|
|
||||||
return &document, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *documentStore) Create(ctx context.Context, document *core.Document) (string, error) {
|
|
||||||
id := ulid.Make().String()
|
|
||||||
filePath := filepath.Join(s.basePath, id)
|
|
||||||
log := logrus.WithFields(logrus.Fields{
|
|
||||||
"document_id": id,
|
|
||||||
"file_path": filePath,
|
|
||||||
})
|
|
||||||
log.Info("Creating new document")
|
|
||||||
|
|
||||||
if err := os.WriteFile(filePath, document.Data.Bytes(), 0644); err != nil {
|
|
||||||
log.WithField("error", err).Error("Failed to create document")
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info("Document created successfully")
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package filesystem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"excalidraw-complete/core"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oklog/ulid/v2"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fsStore struct {
|
||||||
|
basePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore creates a new filesystem-based store.
|
||||||
|
func NewStore(basePath string) *fsStore {
|
||||||
|
if err := os.MkdirAll(basePath, 0755); err != nil {
|
||||||
|
log.Fatalf("failed to create base directory: %v", err)
|
||||||
|
}
|
||||||
|
return &fsStore{basePath: basePath}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocumentStore implementation for anonymous sharing
|
||||||
|
func (s *fsStore) FindID(ctx context.Context, id string) (*core.Document, error) {
|
||||||
|
filePath := filepath.Join(s.basePath, id)
|
||||||
|
log := logrus.WithField("document_id", id)
|
||||||
|
|
||||||
|
log.WithField("file_path", filePath).Info("Retrieving document by ID")
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
log.WithField("error", "document not found").Warn("Document with specified ID not found")
|
||||||
|
return nil, fmt.Errorf("document with id %s not found", id)
|
||||||
|
}
|
||||||
|
log.WithError(err).Error("Failed to retrieve document")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
document := core.Document{
|
||||||
|
Data: *bytes.NewBuffer(data),
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Document retrieved successfully")
|
||||||
|
return &document, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fsStore) Create(ctx context.Context, document *core.Document) (string, error) {
|
||||||
|
id := ulid.Make().String()
|
||||||
|
filePath := filepath.Join(s.basePath, id)
|
||||||
|
log := logrus.WithFields(logrus.Fields{
|
||||||
|
"document_id": id,
|
||||||
|
"file_path": filePath,
|
||||||
|
})
|
||||||
|
log.Info("Creating new document")
|
||||||
|
|
||||||
|
if err := os.WriteFile(filePath, document.Data.Bytes(), 0644); err != nil {
|
||||||
|
log.WithError(err).Error("Failed to create document")
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Document created successfully")
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanvasStore implementation for user-owned canvases
|
||||||
|
func (s *fsStore) getUserCanvasPath(userID string) string {
|
||||||
|
return filepath.Join(s.basePath, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fsStore) List(ctx context.Context, userID string) ([]*core.Canvas, error) {
|
||||||
|
userPath := s.getUserCanvasPath(userID)
|
||||||
|
log := logrus.WithField("user_id", userID).WithField("path", userPath)
|
||||||
|
|
||||||
|
files, err := os.ReadDir(userPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
log.Info("User directory does not exist, returning empty list.")
|
||||||
|
return []*core.Canvas{}, nil
|
||||||
|
}
|
||||||
|
log.WithError(err).Error("Failed to read user directory")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
canvases := make([]*core.Canvas, 0, len(files))
|
||||||
|
for _, file := range files {
|
||||||
|
if !file.IsDir() {
|
||||||
|
info, err := file.Info()
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Warn("Failed to get file info, skipping file")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
canvas := &core.Canvas{
|
||||||
|
ID: file.Name(),
|
||||||
|
UserID: userID,
|
||||||
|
Name: file.Name(),
|
||||||
|
UpdatedAt: info.ModTime(),
|
||||||
|
}
|
||||||
|
canvases = append(canvases, canvas)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("Listed %d canvases", len(canvases))
|
||||||
|
return canvases, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fsStore) Get(ctx context.Context, userID, id string) (*core.Canvas, error) {
|
||||||
|
userPath := s.getUserCanvasPath(userID)
|
||||||
|
filePath := filepath.Join(userPath, id)
|
||||||
|
log := logrus.WithFields(logrus.Fields{"user_id": userID, "canvas_id": id, "path": filePath})
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
log.Warn("Canvas file not found")
|
||||||
|
return nil, fmt.Errorf("canvas %s not found", id)
|
||||||
|
}
|
||||||
|
log.WithError(err).Error("Failed to read canvas file")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(filePath)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("Failed to get file stats")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas := &core.Canvas{
|
||||||
|
ID: id,
|
||||||
|
UserID: userID,
|
||||||
|
Name: id,
|
||||||
|
Data: data,
|
||||||
|
UpdatedAt: info.ModTime(),
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Canvas retrieved successfully")
|
||||||
|
return canvas, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fsStore) Save(ctx context.Context, canvas *core.Canvas) error {
|
||||||
|
userPath := s.getUserCanvasPath(canvas.UserID)
|
||||||
|
filePath := filepath.Join(userPath, canvas.ID)
|
||||||
|
log := logrus.WithFields(logrus.Fields{"user_id": canvas.UserID, "canvas_id": canvas.ID, "path": filePath})
|
||||||
|
|
||||||
|
if err := os.MkdirAll(userPath, 0755); err != nil {
|
||||||
|
log.WithError(err).Error("Failed to create user directory")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Saving canvas")
|
||||||
|
err := os.WriteFile(filePath, canvas.Data, 0644)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("Failed to write canvas file")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set modification time for consistency, though WriteFile usually does this.
|
||||||
|
// We preserve created time logic in the storage layer if needed.
|
||||||
|
now := time.Now()
|
||||||
|
canvas.UpdatedAt = now
|
||||||
|
|
||||||
|
// A full implementation would handle CreatedAt by checking if the file exists first.
|
||||||
|
// For this KV-like store, we'll just update ModTime via WriteFile.
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fsStore) Delete(ctx context.Context, userID, id string) error {
|
||||||
|
userPath := s.getUserCanvasPath(userID)
|
||||||
|
filePath := filepath.Join(userPath, id)
|
||||||
|
log := logrus.WithFields(logrus.Fields{"user_id": userID, "canvas_id": id, "path": filePath})
|
||||||
|
|
||||||
|
err := os.Remove(filePath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
log.Warn("Canvas file not found for deletion, considered successful.")
|
||||||
|
return nil // If it doesn't exist, the goal is achieved.
|
||||||
|
}
|
||||||
|
log.WithError(err).Error("Failed to delete canvas file")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Canvas deleted successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package memory
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"excalidraw-complete/core"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/oklog/ulid/v2"
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
var savedDocuments = make(map[string]core.Document)
|
|
||||||
|
|
||||||
type documentStore struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDocumentStore() core.DocumentStore {
|
|
||||||
return &documentStore{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *documentStore) FindID(ctx context.Context, id string) (*core.Document, error) {
|
|
||||||
log := logrus.WithField("document_id", id)
|
|
||||||
if val, ok := savedDocuments[id]; ok {
|
|
||||||
log.Info("Document retrieved successfully")
|
|
||||||
return &val, nil
|
|
||||||
}
|
|
||||||
log.WithField("error", "document not found").Warn("Document with specified ID not found")
|
|
||||||
return nil, fmt.Errorf("document with id %s not found", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *documentStore) Create(ctx context.Context, document *core.Document) (string, error) {
|
|
||||||
id := ulid.Make().String()
|
|
||||||
savedDocuments[id] = *document
|
|
||||||
log := logrus.WithFields(logrus.Fields{
|
|
||||||
"document_id": id,
|
|
||||||
"data_length": len(document.Data.Bytes()),
|
|
||||||
})
|
|
||||||
log.Info("Document created successfully")
|
|
||||||
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"excalidraw-complete/core"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/oklog/ulid/v2"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
savedDocuments = make(map[string]core.Document)
|
||||||
|
// savedCanvases is a map where the key is userID, and the value is another map
|
||||||
|
// where the key is canvasID and the value is the canvas itself.
|
||||||
|
savedCanvases = make(map[string]map[string]*core.Canvas)
|
||||||
|
mu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// memStore implements both DocumentStore and CanvasStore for in-memory storage.
|
||||||
|
type memStore struct{}
|
||||||
|
|
||||||
|
// NewStore creates a new in-memory store.
|
||||||
|
func NewStore() *memStore {
|
||||||
|
return &memStore{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindID retrieves a document by its ID. Part of the DocumentStore interface.
|
||||||
|
func (s *memStore) FindID(ctx context.Context, id string) (*core.Document, error) {
|
||||||
|
mu.RLock()
|
||||||
|
defer mu.RUnlock()
|
||||||
|
|
||||||
|
log := logrus.WithField("document_id", id)
|
||||||
|
if val, ok := savedDocuments[id]; ok {
|
||||||
|
log.Info("Document retrieved successfully")
|
||||||
|
return &val, nil
|
||||||
|
}
|
||||||
|
log.WithField("error", "document not found").Warn("Document with specified ID not found")
|
||||||
|
return nil, fmt.Errorf("document with id %s not found", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create stores a new document. Part of the DocumentStore interface.
|
||||||
|
func (s *memStore) Create(ctx context.Context, document *core.Document) (string, error) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
id := ulid.Make().String()
|
||||||
|
savedDocuments[id] = *document
|
||||||
|
log := logrus.WithFields(logrus.Fields{
|
||||||
|
"document_id": id,
|
||||||
|
"data_length": len(document.Data.Bytes()),
|
||||||
|
})
|
||||||
|
log.Info("Document created successfully")
|
||||||
|
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns metadata for all canvases owned by a user. Part of the CanvasStore interface.
|
||||||
|
func (s *memStore) List(ctx context.Context, userID string) ([]*core.Canvas, error) {
|
||||||
|
mu.RLock()
|
||||||
|
defer mu.RUnlock()
|
||||||
|
|
||||||
|
userCanvases, ok := savedCanvases[userID]
|
||||||
|
if !ok {
|
||||||
|
return []*core.Canvas{}, nil // No canvases for this user, return empty slice
|
||||||
|
}
|
||||||
|
|
||||||
|
canvases := make([]*core.Canvas, 0, len(userCanvases))
|
||||||
|
for _, canvas := range userCanvases {
|
||||||
|
// Important: create a copy without the large `Data` field for the list view
|
||||||
|
listCanvas := &core.Canvas{
|
||||||
|
ID: canvas.ID,
|
||||||
|
UserID: canvas.UserID,
|
||||||
|
Name: canvas.Name,
|
||||||
|
CreatedAt: canvas.CreatedAt,
|
||||||
|
UpdatedAt: canvas.UpdatedAt,
|
||||||
|
}
|
||||||
|
canvases = append(canvases, listCanvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
logrus.WithField("user_id", userID).Infof("Listed %d canvases", len(canvases))
|
||||||
|
return canvases, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a single canvas by its ID, ensuring it belongs to the user. Part of the CanvasStore interface.
|
||||||
|
func (s *memStore) Get(ctx context.Context, userID, id string) (*core.Canvas, error) {
|
||||||
|
mu.RLock()
|
||||||
|
defer mu.RUnlock()
|
||||||
|
|
||||||
|
log := logrus.WithFields(logrus.Fields{"user_id": userID, "canvas_id": id})
|
||||||
|
|
||||||
|
userCanvases, ok := savedCanvases[userID]
|
||||||
|
if !ok {
|
||||||
|
log.Warn("User has no canvases")
|
||||||
|
return nil, fmt.Errorf("canvas with id %s not found for user %s", id, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas, ok := userCanvases[id]
|
||||||
|
if !ok {
|
||||||
|
log.Warn("Canvas not found for user")
|
||||||
|
return nil, fmt.Errorf("canvas with id %s not found for user %s", id, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Canvas retrieved successfully")
|
||||||
|
return canvas, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save creates or updates a canvas for a user. Part of the CanvasStore interface.
|
||||||
|
func (s *memStore) Save(ctx context.Context, canvas *core.Canvas) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
log := logrus.WithFields(logrus.Fields{"user_id": canvas.UserID, "canvas_id": canvas.ID})
|
||||||
|
|
||||||
|
if canvas.UserID == "" {
|
||||||
|
return fmt.Errorf("UserID cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
userCanvases, ok := savedCanvases[canvas.UserID]
|
||||||
|
if !ok {
|
||||||
|
userCanvases = make(map[string]*core.Canvas)
|
||||||
|
savedCanvases[canvas.UserID] = userCanvases
|
||||||
|
}
|
||||||
|
|
||||||
|
if canvas.ID == "" {
|
||||||
|
return fmt.Errorf("Canvas ID cannot be empty for save operation")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if existingCanvas, exists := userCanvases[canvas.ID]; exists {
|
||||||
|
canvas.CreatedAt = existingCanvas.CreatedAt
|
||||||
|
canvas.UpdatedAt = now
|
||||||
|
} else {
|
||||||
|
canvas.CreatedAt = now
|
||||||
|
canvas.UpdatedAt = now
|
||||||
|
}
|
||||||
|
|
||||||
|
userCanvases[canvas.ID] = canvas
|
||||||
|
log.Info("Canvas saved successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a canvas, ensuring it belongs to the user. Part of the CanvasStore interface.
|
||||||
|
func (s *memStore) Delete(ctx context.Context, userID, id string) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
log := logrus.WithFields(logrus.Fields{"user_id": userID, "canvas_id": id})
|
||||||
|
|
||||||
|
userCanvases, ok := savedCanvases[userID]
|
||||||
|
if !ok {
|
||||||
|
log.Warn("User has no canvases to delete from")
|
||||||
|
return fmt.Errorf("user %s has no canvases", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := userCanvases[id]; !ok {
|
||||||
|
log.Warn("Canvas not found for deletion")
|
||||||
|
return fmt.Errorf("canvas with id %s not found for user %s", id, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(userCanvases, id)
|
||||||
|
log.Info("Canvas deleted successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
package sqlite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"excalidraw-complete/core"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"database/sql"
|
|
||||||
"log"
|
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
|
||||||
"github.com/oklog/ulid/v2"
|
|
||||||
"github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
var savedDocuments = make(map[string]core.Document)
|
|
||||||
|
|
||||||
type documentStore struct {
|
|
||||||
db *sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDocumentStore(dataSourceName string) core.DocumentStore {
|
|
||||||
// db, err := sql.Open("sqlite3", ":memory:")
|
|
||||||
db, err := sql.Open("sqlite3", dataSourceName)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
sts := `CREATE TABLE IF NOT EXISTS documents (id TEXT PRIMARY KEY, data BLOB);`
|
|
||||||
_, err = db.Exec(sts)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
return &documentStore{db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *documentStore) FindID(ctx context.Context, id string) (*core.Document, error) {
|
|
||||||
log := logrus.WithField("document_id", id)
|
|
||||||
log.Debug("Retrieving document by ID")
|
|
||||||
var data []byte
|
|
||||||
err := s.db.QueryRowContext(ctx, "SELECT data FROM documents WHERE id = ?", id).Scan(&data)
|
|
||||||
if err != nil {
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
log.WithField("error", "document not found").Warn("Document with specified ID not found")
|
|
||||||
return nil, fmt.Errorf("document with id %s not found", id)
|
|
||||||
}
|
|
||||||
log.WithField("error", err).Error("Failed to retrieve document")
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
document := core.Document{
|
|
||||||
Data: *bytes.NewBuffer(data),
|
|
||||||
}
|
|
||||||
log.Info("Document retrieved successfully")
|
|
||||||
return &document, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *documentStore) Create(ctx context.Context, document *core.Document) (string, error) {
|
|
||||||
id := ulid.Make().String()
|
|
||||||
data := document.Data.Bytes()
|
|
||||||
log := logrus.WithFields(logrus.Fields{
|
|
||||||
"document_id": id,
|
|
||||||
"data_length": len(data),
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := s.db.ExecContext(ctx, "INSERT INTO documents (id, data) VALUES (?, ?)", id, data)
|
|
||||||
if err != nil {
|
|
||||||
log.WithField("error", err).Error("Failed to create document")
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
log.Info("Document created successfully")
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package sqlite
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"excalidraw-complete/core"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/oklog/ulid/v2"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sqliteStore struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStore creates a new SQLite-based store.
|
||||||
|
func NewStore(dataSourceName string) *sqliteStore {
|
||||||
|
db, err := sql.Open("sqlite3", dataSourceName)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to open sqlite database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize table for anonymous documents
|
||||||
|
docTableStmt := `CREATE TABLE IF NOT EXISTS documents (id TEXT PRIMARY KEY, data BLOB);`
|
||||||
|
if _, err = db.Exec(docTableStmt); err != nil {
|
||||||
|
log.Fatalf("failed to create documents table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize table for user-owned canvases
|
||||||
|
canvasTableStmt := `
|
||||||
|
CREATE TABLE IF NOT EXISTS canvases (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
data BLOB,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME,
|
||||||
|
PRIMARY KEY (user_id, id)
|
||||||
|
);`
|
||||||
|
if _, err = db.Exec(canvasTableStmt); err != nil {
|
||||||
|
log.Fatalf("failed to create canvases table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &sqliteStore{db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocumentStore implementation
|
||||||
|
func (s *sqliteStore) FindID(ctx context.Context, id string) (*core.Document, error) {
|
||||||
|
log := logrus.WithField("document_id", id)
|
||||||
|
log.Debug("Retrieving document by ID")
|
||||||
|
var data []byte
|
||||||
|
err := s.db.QueryRowContext(ctx, "SELECT data FROM documents WHERE id = ?", id).Scan(&data)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
log.WithField("error", "document not found").Warn("Document with specified ID not found")
|
||||||
|
return nil, fmt.Errorf("document with id %s not found", id)
|
||||||
|
}
|
||||||
|
log.WithError(err).Error("Failed to retrieve document")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
document := core.Document{
|
||||||
|
Data: *bytes.NewBuffer(data),
|
||||||
|
}
|
||||||
|
log.Info("Document retrieved successfully")
|
||||||
|
return &document, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) Create(ctx context.Context, document *core.Document) (string, error) {
|
||||||
|
id := ulid.Make().String()
|
||||||
|
data := document.Data.Bytes()
|
||||||
|
log := logrus.WithFields(logrus.Fields{
|
||||||
|
"document_id": id,
|
||||||
|
"data_length": len(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := s.db.ExecContext(ctx, "INSERT INTO documents (id, data) VALUES (?, ?)", id, data)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("Failed to create document")
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
log.Info("Document created successfully")
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanvasStore implementation
|
||||||
|
func (s *sqliteStore) List(ctx context.Context, userID string) ([]*core.Canvas, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, "SELECT id, name, updated_at FROM canvases WHERE user_id = ?", userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var canvases []*core.Canvas
|
||||||
|
for rows.Next() {
|
||||||
|
var canvas core.Canvas
|
||||||
|
canvas.UserID = userID
|
||||||
|
if err := rows.Scan(&canvas.ID, &canvas.Name, &canvas.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
canvases = append(canvases, &canvas)
|
||||||
|
}
|
||||||
|
return canvases, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) Get(ctx context.Context, userID, id string) (*core.Canvas, error) {
|
||||||
|
var canvas core.Canvas
|
||||||
|
canvas.UserID = userID
|
||||||
|
canvas.ID = id
|
||||||
|
err := s.db.QueryRowContext(ctx, "SELECT name, data, created_at, updated_at FROM canvases WHERE user_id = ? AND id = ?", userID, id).Scan(&canvas.Name, &canvas.Data, &canvas.CreatedAt, &canvas.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("canvas not found")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &canvas, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) Save(ctx context.Context, canvas *core.Canvas) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback() // Rollback on any error
|
||||||
|
|
||||||
|
var exists bool
|
||||||
|
err = tx.QueryRowContext(ctx, "SELECT 1 FROM canvases WHERE user_id = ? AND id = ?", canvas.UserID, canvas.ID).Scan(&exists)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
// Update
|
||||||
|
_, err = tx.ExecContext(ctx, "UPDATE canvases SET name = ?, data = ?, updated_at = ? WHERE user_id = ? AND id = ?", canvas.Name, canvas.Data, now, canvas.UserID, canvas.ID)
|
||||||
|
} else {
|
||||||
|
// Insert
|
||||||
|
_, err = tx.ExecContext(ctx, "INSERT INTO canvases (id, user_id, name, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", canvas.ID, canvas.UserID, canvas.Name, canvas.Data, now, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sqliteStore) Delete(ctx context.Context, userID, id string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, "DELETE FROM canvases WHERE user_id = ? AND id = ?", userID, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
+21
-6
@@ -11,9 +11,15 @@ import (
|
|||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetStore() core.DocumentStore {
|
// Store is a union interface that includes all store types.
|
||||||
|
type Store interface {
|
||||||
|
core.DocumentStore
|
||||||
|
core.CanvasStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetStore() Store {
|
||||||
storageType := os.Getenv("STORAGE_TYPE")
|
storageType := os.Getenv("STORAGE_TYPE")
|
||||||
var store core.DocumentStore
|
var store Store
|
||||||
|
|
||||||
storageField := logrus.Fields{
|
storageField := logrus.Fields{
|
||||||
"storageType": storageType,
|
"storageType": storageType,
|
||||||
@@ -22,18 +28,27 @@ func GetStore() core.DocumentStore {
|
|||||||
switch storageType {
|
switch storageType {
|
||||||
case "filesystem":
|
case "filesystem":
|
||||||
basePath := os.Getenv("LOCAL_STORAGE_PATH")
|
basePath := os.Getenv("LOCAL_STORAGE_PATH")
|
||||||
|
if basePath == "" {
|
||||||
|
basePath = "./data" // Default path
|
||||||
|
}
|
||||||
storageField["basePath"] = basePath
|
storageField["basePath"] = basePath
|
||||||
store = filesystem.NewDocumentStore(basePath)
|
store = filesystem.NewStore(basePath)
|
||||||
case "sqlite":
|
case "sqlite":
|
||||||
dataSourceName := os.Getenv("DATA_SOURCE_NAME")
|
dataSourceName := os.Getenv("DATA_SOURCE_NAME")
|
||||||
|
if dataSourceName == "" {
|
||||||
|
dataSourceName = "excalidraw.db" // Default filename
|
||||||
|
}
|
||||||
storageField["dataSourceName"] = dataSourceName
|
storageField["dataSourceName"] = dataSourceName
|
||||||
store = sqlite.NewDocumentStore(dataSourceName)
|
store = sqlite.NewStore(dataSourceName)
|
||||||
case "s3":
|
case "s3":
|
||||||
bucketName := os.Getenv("S3_BUCKET_NAME")
|
bucketName := os.Getenv("S3_BUCKET_NAME")
|
||||||
|
if bucketName == "" {
|
||||||
|
logrus.Fatal("S3_BUCKET_NAME environment variable must be set for s3 storage type")
|
||||||
|
}
|
||||||
storageField["bucketName"] = bucketName
|
storageField["bucketName"] = bucketName
|
||||||
store = aws.NewDocumentStore(bucketName)
|
store = aws.NewStore(bucketName)
|
||||||
default:
|
default:
|
||||||
store = memory.NewDocumentStore()
|
store = memory.NewStore()
|
||||||
storageField["storageType"] = "in-memory"
|
storageField["storageType"] = "in-memory"
|
||||||
}
|
}
|
||||||
logrus.WithFields(storageField).Info("Use storage")
|
logrus.WithFields(storageField).Info("Use storage")
|
||||||
|
|||||||
Reference in New Issue
Block a user