更新项目架构与存储适配器,添加用户认证功能

本次提交包含以下主要更改:
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:
Yuzhong Zhang
2025-07-05 23:13:17 +08:00
parent 61abbc612b
commit 94953a5eac
26 changed files with 2078 additions and 293 deletions
+220
View File
@@ -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)
`
+108
View File
@@ -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密钥。
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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)
`
+106
View File
@@ -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密钥。
+1
Submodule cloudflare-worker added at 5ab2b18e91
+35
View File
@@ -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
}
)
+15
View File
@@ -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.
+10 -7
View File
@@ -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
+6
View File
@@ -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=
+176
View File
@@ -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)
}
}
+207
View File
@@ -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)
}
}
}
+180
View File
@@ -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")
}
+54 -25
View File
@@ -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)
} }
+43
View File
@@ -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))
})
}
-71
View File
@@ -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
}
+160
View File
@@ -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
}
-67
View File
@@ -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
}
+190
View File
@@ -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
}
-41
View File
@@ -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
}
+166
View File
@@ -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
}
-73
View File
@@ -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
}
+157
View File
@@ -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
View File
@@ -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")