From fa80805bb1c2963c3fc40a315809e036bee895ce Mon Sep 17 00:00:00 2001
From: Yuzhong Zhang <141388234+BetterAndBetterII@users.noreply.github.com>
Date: Sun, 17 Aug 2025 20:57:48 +0800
Subject: [PATCH 01/16] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20Dex=20=E8=AE=A4?=
=?UTF-8?q?=E8=AF=81=E9=85=8D=E7=BD=AE=E5=92=8C=E5=88=9D=E5=A7=8B=E5=8C=96?=
=?UTF-8?q?=E8=84=9A=E6=9C=AC=EF=BC=8C=E5=88=9B=E5=BB=BA=20.env=20?=
=?UTF-8?q?=E7=A4=BA=E4=BE=8B=E6=96=87=E4=BB=B6=EF=BC=8C=E6=9B=B4=E6=96=B0?=
=?UTF-8?q?=20docker-compose=20=E9=85=8D=E7=BD=AE=E4=BB=A5=E6=94=AF?=
=?UTF-8?q?=E6=8C=81=20OIDC=20=E8=AE=A4=E8=AF=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env.example.dex | 17 ++++++++++
config/dex.config.yml | 29 ++++++++++++++++
docker-compose.dex.yml | 28 ++++++++++++++++
scripts/init-dex-users.sh | 64 ++++++++++++++++++++++++++++++++++++
scripts/setup-env.sh | 69 +++++++++++++++++++++++++++++++++++++++
5 files changed, 207 insertions(+)
create mode 100644 .env.example.dex
create mode 100644 config/dex.config.yml
create mode 100644 docker-compose.dex.yml
create mode 100644 scripts/init-dex-users.sh
create mode 100644 scripts/setup-env.sh
diff --git a/.env.example.dex b/.env.example.dex
new file mode 100644
index 0000000..613acc2
--- /dev/null
+++ b/.env.example.dex
@@ -0,0 +1,17 @@
+OIDC_ISSUER_URL=http://localhost:5556/.well-known/openid-configuration
+OIDC_CLIENT_ID=excalidraw
+OIDC_CLIENT_SECRET=excalidraw-secret
+OIDC_REDIRECT_URL=http://localhost:3002/auth/oidc/callback
+
+ADMIN_USERNAME=admin
+ADMIN_PASSWORD=your_secure_password
+ADMIN_EMAIL=admin@example.com
+
+JWT_SECRET=your_super_secret_jwt_string
+
+STORAGE_TYPE=sqlite
+DATA_SOURCE_NAME=excalidraw.db
+LOCAL_STORAGE_PATH=./data
+
+OPENAI_API_KEY=sk-your_openai_api_key
+OPENAI_BASE_URL=https://api.openai.com
diff --git a/config/dex.config.yml b/config/dex.config.yml
new file mode 100644
index 0000000..c890277
--- /dev/null
+++ b/config/dex.config.yml
@@ -0,0 +1,29 @@
+issuer: http://localhost:5556
+
+storage:
+ type: sqlite3
+ config:
+ file: /var/lib/dex/dex.db
+
+web:
+ http: 0.0.0.0:5556
+ allowedOrigins: ["*"]
+
+logger:
+ level: debug
+ format: text
+
+enablePasswordDB: true
+
+staticClients:
+- id: excalidraw
+ redirectURIs:
+ - http://localhost:3002/auth/oidc/callback
+ name: Excalidraw
+ secret: ${OIDC_CLIENT_SECRET:-excalidraw-secret}
+
+staticPasswords:
+- email: ${ADMIN_EMAIL:-admin@example.com}
+ hash: ${ADMIN_PASSWORD_HASH}
+ username: ${ADMIN_USERNAME:-admin}
+ userID: "admin-001"
\ No newline at end of file
diff --git a/docker-compose.dex.yml b/docker-compose.dex.yml
new file mode 100644
index 0000000..f28b353
--- /dev/null
+++ b/docker-compose.dex.yml
@@ -0,0 +1,28 @@
+version: '3.8'
+
+services:
+ dex:
+ image: dexidp/dex:v2.38.0
+ container_name: excalidraw-dex
+ restart: unless-stopped
+ ports:
+ - "5556:5556"
+ volumes:
+ - ./config/dex.config.yml:/etc/dex/config.yml
+ - dex-data:/var/lib/dex
+ environment:
+ - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
+ - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
+ - ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
+ - ADMIN_PASSWORD=${ADMIN_PASSWORD}
+ - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
+ - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-excalidraw-secret}
+ networks:
+ - dex-network
+
+volumes:
+ dex-data:
+
+networks:
+ dex-network:
+ driver: bridge
diff --git a/scripts/init-dex-users.sh b/scripts/init-dex-users.sh
new file mode 100644
index 0000000..4e8b767
--- /dev/null
+++ b/scripts/init-dex-users.sh
@@ -0,0 +1,64 @@
+#!/bin/bash
+
+set -e
+
+# 检查环境变量
+if [ -z "$ADMIN_PASSWORD" ]; then
+ echo "错误: 请设置 ADMIN_PASSWORD 环境变量"
+ exit 1
+fi
+
+# 生成密码哈希
+echo "正在生成密码哈希..."
+PASSWORD_HASH=$(docker run --rm dexidp/dex:v2.38.0 hash --password="$ADMIN_PASSWORD")
+
+# 创建临时配置文件
+cat > /tmp/dex-init-config.yml << EOF
+issuer: http://localhost:5556
+
+storage:
+ type: sqlite3
+ config:
+ file: /var/lib/dex/dex.db
+
+web:
+ http: 0.0.0.0:5556
+
+logger:
+ level: info
+
+enablePasswordDB: true
+
+staticPasswords:
+- email: ${ADMIN_EMAIL:-admin@example.com}
+ hash: $PASSWORD_HASH
+ username: ${ADMIN_USERNAME:-admin}
+ userID: "admin-001"
+EOF
+
+# 初始化 Dex 数据库
+echo "正在初始化 Dex 数据库..."
+docker run --rm \
+ -v $(pwd)/config/dex.config.yml:/etc/dex/config.yml \
+ -v dex-data:/var/lib/dex \
+ dexidp/dex:v2.38.0 \
+ serve /etc/dex/config.yml &
+DEX_PID=$!
+
+# 等待 Dex 启动
+echo "等待 Dex 启动..."
+sleep 10
+
+# 停止临时 Dex 进程
+kill $DEX_PID 2>/dev/null || true
+
+echo "Dex 用户初始化完成!"
+echo "管理员账户:"
+echo " 用户名: ${ADMIN_USERNAME:-admin}"
+echo " 邮箱: ${ADMIN_EMAIL:-admin@example.com}"
+echo " 密码: $ADMIN_PASSWORD"
+echo ""
+echo "请使用以下凭据登录:"
+echo " Dex UI: http://localhost:5556"
+echo " 用户名: ${ADMIN_USERNAME:-admin}"
+echo " 密码: $ADMIN_PASSWORD"
\ No newline at end of file
diff --git a/scripts/setup-env.sh b/scripts/setup-env.sh
new file mode 100644
index 0000000..e698721
--- /dev/null
+++ b/scripts/setup-env.sh
@@ -0,0 +1,69 @@
+#!/bin/bash
+
+# 生成随机密码
+generate_password() {
+ openssl rand -base64 16 | tr -d "=+/" | cut -c1-16
+}
+
+# 生成 JWT 密钥
+generate_jwt_secret() {
+ openssl rand -base64 32 | tr -d "=+/" | cut -c1-32
+}
+
+echo "正在生成环境变量配置..."
+
+# 生成随机密码和密钥
+ADMIN_PASSWORD=$(generate_password)
+JWT_SECRET=$(generate_jwt_secret)
+OIDC_CLIENT_SECRET=$(generate_password)
+
+# 创建 .env 文件
+cat > .env << EOF
+# === 认证配置 ===
+AUTH_TYPE=oidc
+
+# GitHub OAuth 配置 (可选)
+# GITHUB_CLIENT_ID=your_github_client_id
+# GITHUB_CLIENT_SECRET=your_github_client_secret
+# GITHUB_REDIRECT_URL=http://localhost:3002/auth/github/callback
+
+# OIDC 配置
+OIDC_ISSUER_URL=http://localhost:5556/.well-known/openid-configuration
+OIDC_CLIENT_ID=excalidraw
+OIDC_CLIENT_SECRET=$OIDC_CLIENT_SECRET
+OIDC_REDIRECT_URL=http://localhost:3002/auth/oidc/callback
+
+# Dex 配置
+OIDC_CLIENT_SECRET=$OIDC_CLIENT_SECRET
+ADMIN_USERNAME=admin
+ADMIN_PASSWORD=$ADMIN_PASSWORD
+ADMIN_EMAIL=admin@example.com
+
+# === JWT 配置 ===
+JWT_SECRET=$JWT_SECRET
+
+# === 存储配置 ===
+STORAGE_TYPE=sqlite
+DATA_SOURCE_NAME=excalidraw.db
+LOCAL_STORAGE_PATH=./data
+
+# === 应用配置 ===
+LISTEN=:3002
+LOG_LEVEL=info
+
+# === OpenAI 配置 (可选) ===
+# OPENAI_API_KEY=sk-your_openai_api_key
+# OPENAI_BASE_URL=https://api.openai.com
+EOF
+
+echo "环境变量配置已生成到 .env 文件"
+echo ""
+echo "重要信息请保存:"
+echo " 管理员密码: $ADMIN_PASSWORD"
+echo " JWT 密钥: $JWT_SECRET"
+echo " Dex 客户端密钥: $OIDC_CLIENT_SECRET"
+echo ""
+echo "请运行以下命令启动服务:"
+echo " 1. docker-compose -f docker-compose.dex.yml up -d"
+echo " 2. ./scripts/init-dex-users.sh"
+echo " 3. docker-compose up -d"
\ No newline at end of file
From 4da39f2d6ad6ee392c434b227a52134cf56a0322 Mon Sep 17 00:00:00 2001
From: Yuzhong Zhang <141388234+BetterAndBetterII@users.noreply.github.com>
Date: Mon, 18 Aug 2025 20:50:43 +0800
Subject: [PATCH 02/16] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20OIDC=20=E8=AE=A4?=
=?UTF-8?q?=E8=AF=81=E6=94=AF=E6=8C=81=EF=BC=8C=E6=9B=B4=E6=96=B0=E7=8E=AF?=
=?UTF-8?q?=E5=A2=83=E5=8F=98=E9=87=8F=E9=85=8D=E7=BD=AE=EF=BC=8C=E9=87=8D?=
=?UTF-8?q?=E6=9E=84=20Docker=20Compose=20=E6=96=87=E4=BB=B6=EF=BC=8C?=
=?UTF-8?q?=E7=A7=BB=E9=99=A4=E6=97=A7=E7=9A=84=20Dex=20=E5=88=9D=E5=A7=8B?=
=?UTF-8?q?=E5=8C=96=E8=84=9A=E6=9C=AC=EF=BC=8C=E4=BC=98=E5=8C=96=E7=94=A8?=
=?UTF-8?q?=E6=88=B7=E6=A8=A1=E5=9E=8B=EF=BC=8C=E6=9B=B4=E6=96=B0=E5=89=8D?=
=?UTF-8?q?=E7=AB=AF=E7=99=BB=E5=BD=95=E6=B5=81=E7=A8=8B=EF=BC=8C=E6=94=AF?=
=?UTF-8?q?=E6=8C=81=E9=80=9A=E8=BF=87=20OIDC=20=E7=99=BB=E5=BD=95?=
=?UTF-8?q?=E3=80=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env.example | 14 +++-
config/dex.config.yaml | 28 +++++++
config/dex.config.yml | 29 -------
core/user.go | 5 +-
docker-compose.dex.yml | 28 ++++---
go.mod | 2 +
go.sum | 4 +
handlers/auth/dex.go | 155 ++++++++++++++++++++++++++++++++++++++
handlers/auth/github.go | 8 +-
main.go | 6 ++
scripts/init-dex-users.sh | 64 ----------------
scripts/setup-env.sh | 69 -----------------
12 files changed, 233 insertions(+), 179 deletions(-)
create mode 100644 config/dex.config.yaml
delete mode 100644 config/dex.config.yml
create mode 100644 handlers/auth/dex.go
delete mode 100644 scripts/init-dex-users.sh
delete mode 100644 scripts/setup-env.sh
diff --git a/.env.example b/.env.example
index 891833b..ff79f77 100644
--- a/.env.example
+++ b/.env.example
@@ -1,8 +1,20 @@
+# GitHub OAuth 配置
GITHUB_CLIENT_ID="xxxxxxxxxxxxxxxxxxx"
GITHUB_CLIENT_SECRET="xxxxxxxxxxxxxx"
-GITHUB_REDIRECT_URL="http://localhost:3000/auth/github/callback" # 或者你部署后的回调地址
+GITHUB_REDIRECT_URL="http://localhost:3002/auth/github/callback" # 或者你部署后的回调地址
+
+# OIDC 配置
+OIDC_ISSUER_URL="http://localhost:5556/.well-known/openid-configuration" # OIDC 提供商地址
+OIDC_CLIENT_ID="excalidraw"
+OIDC_CLIENT_SECRET="excalidraw-secret"
+OIDC_REDIRECT_URL="http://localhost:3002/auth/oidc/callback"
+
+# JWT 配置
JWT_SECRET="YOUR_SUPER_SECRET_RANDOM_STRING"
+# OpenAI 配置
OPENAI_API_KEY=sk-xxxxxxxxxxxxxx
OPENAI_BASE_URL=https://xxxxxx.xxxxx # 不加/v1...
+
+# 存储配置
STORAGE_TYPE=sqlite # 支持memory,filesystem, kv, s3,具体看README
diff --git a/config/dex.config.yaml b/config/dex.config.yaml
new file mode 100644
index 0000000..f37b1ad
--- /dev/null
+++ b/config/dex.config.yaml
@@ -0,0 +1,28 @@
+issuer: http://localhost:5556
+
+storage:
+ type: memory
+
+web:
+ http: 0.0.0.0:5556
+ allowedOrigins: ["*"]
+
+logger:
+ level: debug
+ format: text
+
+enablePasswordDB: true
+
+staticClients:
+ - id: excalidraw
+ redirectURIs:
+ - {{ .Env.OIDC_REDIRECT_URL }}
+ name: Excalidraw
+ public: true
+ secret: excalidraw-secret
+
+staticPasswords:
+ - email: {{ .Env.ADMIN_EMAIL }}
+ hash: {{ .Env.ADMIN_PASSWORD_HASH }}
+ username: {{ .Env.ADMIN_USERNAME }}
+ userID: {{ .Env.ADMIN_USER_ID }}
diff --git a/config/dex.config.yml b/config/dex.config.yml
deleted file mode 100644
index c890277..0000000
--- a/config/dex.config.yml
+++ /dev/null
@@ -1,29 +0,0 @@
-issuer: http://localhost:5556
-
-storage:
- type: sqlite3
- config:
- file: /var/lib/dex/dex.db
-
-web:
- http: 0.0.0.0:5556
- allowedOrigins: ["*"]
-
-logger:
- level: debug
- format: text
-
-enablePasswordDB: true
-
-staticClients:
-- id: excalidraw
- redirectURIs:
- - http://localhost:3002/auth/oidc/callback
- name: Excalidraw
- secret: ${OIDC_CLIENT_SECRET:-excalidraw-secret}
-
-staticPasswords:
-- email: ${ADMIN_EMAIL:-admin@example.com}
- hash: ${ADMIN_PASSWORD_HASH}
- username: ${ADMIN_USERNAME:-admin}
- userID: "admin-001"
\ No newline at end of file
diff --git a/core/user.go b/core/user.go
index 9a5a4c3..aad8f8e 100644
--- a/core/user.go
+++ b/core/user.go
@@ -5,8 +5,9 @@ import "time"
type (
User struct {
ID uint `json:"id" gorm:"primarykey"`
- GitHubID int64 `json:"githubId" gorm:"unique"`
- Login string `json:"login"`
+ Subject string `json:"subject" gorm:"uniqueIndex"`
+ Login string `json:"login" gorm:"uniqueIndex"`
+ Email string `json:"email"`
AvatarURL string `json:"avatarUrl"`
Name string `json:"name"`
CreatedAt time.Time `json:"createdAt"`
diff --git a/docker-compose.dex.yml b/docker-compose.dex.yml
index f28b353..daab2f3 100644
--- a/docker-compose.dex.yml
+++ b/docker-compose.dex.yml
@@ -8,20 +8,30 @@ services:
ports:
- "5556:5556"
volumes:
- - ./config/dex.config.yml:/etc/dex/config.yml
- - dex-data:/var/lib/dex
+ - ./config/dex.config.yaml:/etc/dex/config.yaml
environment:
- - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
- - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
- - ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- - ADMIN_PASSWORD=${ADMIN_PASSWORD}
- - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
+ - OIDC_REDIRECT_URL=${OIDC_REDIRECT_URL:-http://localhost:3000/auth/oidc/callback}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-excalidraw-secret}
+ - OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-excalidraw}
+ - OIDC_ISSUER=${OIDC_ISSUER:-http://localhost:5556}
+ - ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
+ - ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH:-your_secure_password}
+ - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
+ - ADMIN_USER_ID=${ADMIN_USER_ID:-'admin1234'}
+ command: ["dex", "serve", "/etc/dex/config.yaml"]
networks:
- dex-network
-volumes:
- dex-data:
+ excalidraw:
+ build:
+ context: .
+ dockerfile: excalidraw-complete.Dockerfile
+ ports:
+ - "3003:3002"
+ volumes:
+ - ./data:/root/data
+ - ./excalidraw.db:/root/excalidraw.db:Z
+ - ./.env:/root/.env
networks:
dex-network:
diff --git a/go.mod b/go.mod
index a51157a..685f320 100644
--- a/go.mod
+++ b/go.mod
@@ -39,7 +39,9 @@ require (
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/smithy-go v1.20.1 // indirect
+ github.com/coreos/go-oidc/v3 v3.15.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/google/uuid v1.6.0 // indirect
diff --git a/go.sum b/go.sum
index 3d8aa03..509f335 100644
--- a/go.sum
+++ b/go.sum
@@ -38,6 +38,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.28.5 h1:J/PpTf/hllOjx8Xu9DMflff3Fajf
github.com/aws/aws-sdk-go-v2/service/sts v1.28.5/go.mod h1:0ih0Z83YDH/QeQ6Ori2yGE2XvWYv/Xm+cZc01LC6oK0=
github.com/aws/smithy-go v1.20.1 h1:4SZlSlMr36UEqC7XOyRVb27XMeZubNcBNN+9IgEPIQw=
github.com/aws/smithy-go v1.20.1/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
+github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
+github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -51,6 +53,8 @@ github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
+github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
+github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
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-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
diff --git a/handlers/auth/dex.go b/handlers/auth/dex.go
new file mode 100644
index 0000000..371e3a5
--- /dev/null
+++ b/handlers/auth/dex.go
@@ -0,0 +1,155 @@
+package auth
+
+import (
+ "context"
+ "excalidraw-complete/core"
+ "fmt"
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/coreos/go-oidc/v3/oidc"
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/sirupsen/logrus"
+ "golang.org/x/oauth2"
+)
+
+var (
+ oidcOauthConfig *oauth2.Config
+ oidcProvider *oidc.Provider
+ verifier *oidc.IDTokenVerifier
+)
+
+// OIDCClaims represents the claims from OIDC token
+type OIDCClaims struct {
+ Email string `json:"email"`
+ Name string `json:"name"`
+ PreferredUsername string `json:"preferred_username"`
+ Picture string `json:"picture"`
+ Sub string `json:"sub"`
+}
+
+func InitDex() {
+ providerURL := os.Getenv("OIDC_ISSUER_URL")
+ clientID := os.Getenv("OIDC_CLIENT_ID")
+ clientSecret := os.Getenv("OIDC_CLIENT_SECRET")
+ redirectURL := os.Getenv("OIDC_REDIRECT_URL")
+
+ if providerURL == "" || clientID == "" || clientSecret == "" {
+ logrus.Warn("OIDC credentials are not set. OIDC authentication routes will not work.")
+ return
+ }
+
+ var err error
+ oidcProvider, err = oidc.NewProvider(context.Background(), providerURL)
+ if err != nil {
+ logrus.Errorf("Failed to create OIDC provider: %s", err.Error())
+ return
+ }
+
+ oidcOauthConfig = &oauth2.Config{
+ ClientID: clientID,
+ ClientSecret: clientSecret,
+ RedirectURL: redirectURL,
+ Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
+ Endpoint: oidcProvider.Endpoint(),
+ }
+
+ logrus.Info("OIDC provider initialized")
+
+ verifier = oidcProvider.Verifier(&oidc.Config{
+ ClientID: clientID,
+ })
+}
+
+func HandleOIDCLogin(w http.ResponseWriter, r *http.Request) {
+ if oidcOauthConfig == nil {
+ http.Error(w, "OIDC is not configured", http.StatusInternalServerError)
+ return
+ }
+
+ url := oidcOauthConfig.AuthCodeURL("random", oauth2.AccessTypeOffline)
+ http.Redirect(w, r, url, http.StatusTemporaryRedirect)
+}
+
+func HandleOIDCCallback(w http.ResponseWriter, r *http.Request) {
+ if oidcOauthConfig == nil {
+ http.Error(w, "OIDC is not configured", http.StatusInternalServerError)
+ return
+ }
+
+ code := r.FormValue("code")
+ if code == "" {
+ logrus.Error("no code in callback")
+ http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
+ return
+ }
+
+ token, err := oidcOauthConfig.Exchange(context.Background(), code)
+ if err != nil {
+ logrus.Errorf("failed to exchange token: %s", err.Error())
+ http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
+ return
+ }
+
+ rawIDToken, ok := token.Extra("id_token").(string)
+ if !ok {
+ logrus.Error("no id_token in token response")
+ http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
+ return
+ }
+
+ idToken, err := verifier.Verify(context.Background(), rawIDToken)
+ if err != nil {
+ logrus.Errorf("failed to verify ID token: %s", err.Error())
+ http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
+ return
+ }
+
+ var claims OIDCClaims
+ if err := idToken.Claims(&claims); err != nil {
+ logrus.Errorf("failed to extract claims from ID token: %s", err.Error())
+ http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
+ return
+ }
+
+ // Create user from OIDC claims
+ user := &core.User{
+ Subject: claims.Sub,
+ Login: claims.PreferredUsername,
+ Email: claims.Email,
+ AvatarURL: claims.Picture,
+ Name: claims.Name,
+ }
+
+ // If preferred_username is not available, use email
+ if user.Login == "" && user.Email != "" {
+ user.Login = user.Email
+ }
+
+ jwtToken, err := createOIDCJWT(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 createOIDCJWT(user *core.User) (string, error) {
+ claims := AppClaims{
+ RegisteredClaims: jwt.RegisteredClaims{
+ Subject: user.Subject,
+ 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)
+}
diff --git a/handlers/auth/github.go b/handlers/auth/github.go
index 55bc65b..4986b8a 100644
--- a/handlers/auth/github.go
+++ b/handlers/auth/github.go
@@ -23,7 +23,6 @@ var (
jwtSecret []byte
)
-const oauthStateString = "random"
// AppClaims represents the custom claims for the JWT.
type AppClaims struct {
@@ -124,10 +123,9 @@ func HandleGitHubCallback(w http.ResponseWriter, r *http.Request) {
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.
+ // Create user object using Subject instead of GitHubID
user := &core.User{
- GitHubID: githubUser.ID,
+ Subject: fmt.Sprintf("github:%d", githubUser.ID),
Login: githubUser.Login,
AvatarURL: githubUser.AvatarURL,
Name: githubUser.Name,
@@ -147,7 +145,7 @@ func HandleGitHubCallback(w http.ResponseWriter, r *http.Request) {
func createJWT(user *core.User) (string, error) {
claims := AppClaims{
RegisteredClaims: jwt.RegisteredClaims{
- Subject: fmt.Sprintf("%d", user.GitHubID),
+ Subject: user.Subject,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 7)), // 1 week
IssuedAt: jwt.NewNumericDate(time.Now()),
},
diff --git a/main.go b/main.go
index ace7ab3..e601d72 100644
--- a/main.go
+++ b/main.go
@@ -171,6 +171,11 @@ func setupRouter(store stores.Store) *chi.Mux {
r.Get("/callback", auth.HandleGitHubCallback)
})
+ r.Route("/auth/oidc", func(r chi.Router) {
+ r.Get("/login", auth.HandleOIDCLogin)
+ r.Get("/callback", auth.HandleOIDCCallback)
+ })
+
return r
}
@@ -307,6 +312,7 @@ func main() {
})
auth.Init()
+ auth.InitDex()
openai.Init()
store := stores.GetStore()
diff --git a/scripts/init-dex-users.sh b/scripts/init-dex-users.sh
deleted file mode 100644
index 4e8b767..0000000
--- a/scripts/init-dex-users.sh
+++ /dev/null
@@ -1,64 +0,0 @@
-#!/bin/bash
-
-set -e
-
-# 检查环境变量
-if [ -z "$ADMIN_PASSWORD" ]; then
- echo "错误: 请设置 ADMIN_PASSWORD 环境变量"
- exit 1
-fi
-
-# 生成密码哈希
-echo "正在生成密码哈希..."
-PASSWORD_HASH=$(docker run --rm dexidp/dex:v2.38.0 hash --password="$ADMIN_PASSWORD")
-
-# 创建临时配置文件
-cat > /tmp/dex-init-config.yml << EOF
-issuer: http://localhost:5556
-
-storage:
- type: sqlite3
- config:
- file: /var/lib/dex/dex.db
-
-web:
- http: 0.0.0.0:5556
-
-logger:
- level: info
-
-enablePasswordDB: true
-
-staticPasswords:
-- email: ${ADMIN_EMAIL:-admin@example.com}
- hash: $PASSWORD_HASH
- username: ${ADMIN_USERNAME:-admin}
- userID: "admin-001"
-EOF
-
-# 初始化 Dex 数据库
-echo "正在初始化 Dex 数据库..."
-docker run --rm \
- -v $(pwd)/config/dex.config.yml:/etc/dex/config.yml \
- -v dex-data:/var/lib/dex \
- dexidp/dex:v2.38.0 \
- serve /etc/dex/config.yml &
-DEX_PID=$!
-
-# 等待 Dex 启动
-echo "等待 Dex 启动..."
-sleep 10
-
-# 停止临时 Dex 进程
-kill $DEX_PID 2>/dev/null || true
-
-echo "Dex 用户初始化完成!"
-echo "管理员账户:"
-echo " 用户名: ${ADMIN_USERNAME:-admin}"
-echo " 邮箱: ${ADMIN_EMAIL:-admin@example.com}"
-echo " 密码: $ADMIN_PASSWORD"
-echo ""
-echo "请使用以下凭据登录:"
-echo " Dex UI: http://localhost:5556"
-echo " 用户名: ${ADMIN_USERNAME:-admin}"
-echo " 密码: $ADMIN_PASSWORD"
\ No newline at end of file
diff --git a/scripts/setup-env.sh b/scripts/setup-env.sh
deleted file mode 100644
index e698721..0000000
--- a/scripts/setup-env.sh
+++ /dev/null
@@ -1,69 +0,0 @@
-#!/bin/bash
-
-# 生成随机密码
-generate_password() {
- openssl rand -base64 16 | tr -d "=+/" | cut -c1-16
-}
-
-# 生成 JWT 密钥
-generate_jwt_secret() {
- openssl rand -base64 32 | tr -d "=+/" | cut -c1-32
-}
-
-echo "正在生成环境变量配置..."
-
-# 生成随机密码和密钥
-ADMIN_PASSWORD=$(generate_password)
-JWT_SECRET=$(generate_jwt_secret)
-OIDC_CLIENT_SECRET=$(generate_password)
-
-# 创建 .env 文件
-cat > .env << EOF
-# === 认证配置 ===
-AUTH_TYPE=oidc
-
-# GitHub OAuth 配置 (可选)
-# GITHUB_CLIENT_ID=your_github_client_id
-# GITHUB_CLIENT_SECRET=your_github_client_secret
-# GITHUB_REDIRECT_URL=http://localhost:3002/auth/github/callback
-
-# OIDC 配置
-OIDC_ISSUER_URL=http://localhost:5556/.well-known/openid-configuration
-OIDC_CLIENT_ID=excalidraw
-OIDC_CLIENT_SECRET=$OIDC_CLIENT_SECRET
-OIDC_REDIRECT_URL=http://localhost:3002/auth/oidc/callback
-
-# Dex 配置
-OIDC_CLIENT_SECRET=$OIDC_CLIENT_SECRET
-ADMIN_USERNAME=admin
-ADMIN_PASSWORD=$ADMIN_PASSWORD
-ADMIN_EMAIL=admin@example.com
-
-# === JWT 配置 ===
-JWT_SECRET=$JWT_SECRET
-
-# === 存储配置 ===
-STORAGE_TYPE=sqlite
-DATA_SOURCE_NAME=excalidraw.db
-LOCAL_STORAGE_PATH=./data
-
-# === 应用配置 ===
-LISTEN=:3002
-LOG_LEVEL=info
-
-# === OpenAI 配置 (可选) ===
-# OPENAI_API_KEY=sk-your_openai_api_key
-# OPENAI_BASE_URL=https://api.openai.com
-EOF
-
-echo "环境变量配置已生成到 .env 文件"
-echo ""
-echo "重要信息请保存:"
-echo " 管理员密码: $ADMIN_PASSWORD"
-echo " JWT 密钥: $JWT_SECRET"
-echo " Dex 客户端密钥: $OIDC_CLIENT_SECRET"
-echo ""
-echo "请运行以下命令启动服务:"
-echo " 1. docker-compose -f docker-compose.dex.yml up -d"
-echo " 2. ./scripts/init-dex-users.sh"
-echo " 3. docker-compose up -d"
\ No newline at end of file
From bff23e9117dd208c222b7bd174e7ee41de7ad0b0 Mon Sep 17 00:00:00 2001
From: BetterAndBetterII <1176445047@qq.com>
Date: Mon, 18 Aug 2025 10:53:37 +0000
Subject: [PATCH 03/16] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20.env=20=E7=A4=BA?=
=?UTF-8?q?=E4=BE=8B=E6=96=87=E4=BB=B6=E4=BB=A5=E5=8C=85=E5=90=AB=E6=96=B0?=
=?UTF-8?q?=E7=9A=84=E7=AE=A1=E7=90=86=E5=91=98=E7=94=A8=E6=88=B7=20ID=20?=
=?UTF-8?q?=E5=92=8C=E5=AF=86=E7=A0=81=E5=93=88=E5=B8=8C=EF=BC=8C=E8=B0=83?=
=?UTF-8?q?=E6=95=B4=20docker-compose=20=E9=85=8D=E7=BD=AE=E4=BB=A5?=
=?UTF-8?q?=E6=94=AF=E6=8C=81=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5=E5=92=8C?=
=?UTF-8?q?=E7=BD=91=E7=BB=9C=E8=AE=BE=E7=BD=AE=EF=BC=8C=E4=BC=98=E5=8C=96?=
=?UTF-8?q?=20Dex=20=E9=85=8D=E7=BD=AE=E4=BB=A5=E4=BD=BF=E7=94=A8=E7=8E=AF?=
=?UTF-8?q?=E5=A2=83=E5=8F=98=E9=87=8F=E3=80=82=E6=AD=A4=E6=9B=B4=E6=94=B9?=
=?UTF-8?q?=E5=A2=9E=E5=BC=BA=E4=BA=86=20OIDC=20=E8=AE=A4=E8=AF=81?=
=?UTF-8?q?=E7=9A=84=E7=81=B5=E6=B4=BB=E6=80=A7=E5=92=8C=E5=AE=89=E5=85=A8?=
=?UTF-8?q?=E6=80=A7=E3=80=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env.example.dex | 5 +++--
config/dex.config.yaml | 2 +-
docker-compose.dex.yml | 34 +++++++++++++++++++++++-----------
3 files changed, 27 insertions(+), 14 deletions(-)
diff --git a/.env.example.dex b/.env.example.dex
index 613acc2..fb19cf8 100644
--- a/.env.example.dex
+++ b/.env.example.dex
@@ -1,11 +1,12 @@
-OIDC_ISSUER_URL=http://localhost:5556/.well-known/openid-configuration
+OIDC_ISSUER_URL=http://localhost:5556
OIDC_CLIENT_ID=excalidraw
OIDC_CLIENT_SECRET=excalidraw-secret
OIDC_REDIRECT_URL=http://localhost:3002/auth/oidc/callback
ADMIN_USERNAME=admin
-ADMIN_PASSWORD=your_secure_password
+ADMIN_PASSWORD_HASH='$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W'
ADMIN_EMAIL=admin@example.com
+ADMIN_USER_ID=admin1234
JWT_SECRET=your_super_secret_jwt_string
diff --git a/config/dex.config.yaml b/config/dex.config.yaml
index f37b1ad..5dc384d 100644
--- a/config/dex.config.yaml
+++ b/config/dex.config.yaml
@@ -1,4 +1,4 @@
-issuer: http://localhost:5556
+issuer: {{ .Env.OIDC_ISSUER }}
storage:
type: memory
diff --git a/docker-compose.dex.yml b/docker-compose.dex.yml
index daab2f3..4233ec1 100644
--- a/docker-compose.dex.yml
+++ b/docker-compose.dex.yml
@@ -1,16 +1,23 @@
version: '3.8'
services:
+ netpod:
+ image: busybox:latest
+ ports:
+ - "5556:5556" # Dex
+ - "3002:3002" # Excalidraw
+ command: ["sleep", "infinity"]
+ networks:
+ - excalidraw-network
+
dex:
image: dexidp/dex:v2.38.0
container_name: excalidraw-dex
restart: unless-stopped
- ports:
- - "5556:5556"
volumes:
- ./config/dex.config.yaml:/etc/dex/config.yaml
environment:
- - OIDC_REDIRECT_URL=${OIDC_REDIRECT_URL:-http://localhost:3000/auth/oidc/callback}
+ - OIDC_REDIRECT_URL=${OIDC_REDIRECT_URL:-http://localhost:3002/auth/oidc/callback}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-excalidraw-secret}
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-excalidraw}
- OIDC_ISSUER=${OIDC_ISSUER:-http://localhost:5556}
@@ -19,20 +26,25 @@ services:
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
- ADMIN_USER_ID=${ADMIN_USER_ID:-'admin1234'}
command: ["dex", "serve", "/etc/dex/config.yaml"]
- networks:
- - dex-network
+ healthcheck:
+ test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:5556/.well-known/openid-configuration"]
+ interval: 1s
+ timeout: 1s
+ retries: 10
+ start_period: 10s
+ network_mode: service:netpod
excalidraw:
- build:
- context: .
- dockerfile: excalidraw-complete.Dockerfile
- ports:
- - "3003:3002"
+ image: ghcr.io/betterandbetterii/excalidraw-full:dex-oidc
volumes:
- ./data:/root/data
- ./excalidraw.db:/root/excalidraw.db:Z
- ./.env:/root/.env
+ depends_on:
+ dex:
+ condition: service_healthy
+ network_mode: service:netpod
networks:
- dex-network:
+ excalidraw-network:
driver: bridge
From aa8e9ed4d0f51afd27043f4fef5a6a53cdb94d27 Mon Sep 17 00:00:00 2001
From: BetterAndBetterII <1176445047@qq.com>
Date: Mon, 18 Aug 2025 19:33:48 +0800
Subject: [PATCH 04/16] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20.env=20=E7=A4=BA?=
=?UTF-8?q?=E4=BE=8B=E6=96=87=E4=BB=B6=E4=BB=A5=E4=BF=AE=E6=AD=A3=20OIDC?=
=?UTF-8?q?=20=E9=87=8D=E5=AE=9A=E5=90=91=20URL=EF=BC=8C=E6=B7=BB=E5=8A=A0?=
=?UTF-8?q?=E5=8F=AF=E9=80=89=E7=9A=84=E7=AE=A1=E7=90=86=E5=91=98=E7=94=A8?=
=?UTF-8?q?=E6=88=B7=E5=90=8D=E6=B3=A8=E9=87=8A=E3=80=82=E5=A2=9E=E5=BC=BA?=
=?UTF-8?q?=20OIDC=20=E7=99=BB=E5=BD=95=E6=B5=81=E7=A8=8B=EF=BC=8C?=
=?UTF-8?q?=E7=94=9F=E6=88=90=E9=9A=8F=E6=9C=BA=E7=8A=B6=E6=80=81=E5=B9=B6?=
=?UTF-8?q?=E5=9C=A8=20Cookie=20=E4=B8=AD=E5=AD=98=E5=82=A8=EF=BC=8C?=
=?UTF-8?q?=E9=AA=8C=E8=AF=81=E7=8A=B6=E6=80=81=E4=BB=A5=E6=8F=90=E9=AB=98?=
=?UTF-8?q?=E5=AE=89=E5=85=A8=E6=80=A7=E3=80=82=E6=AD=A4=E6=9B=B4=E6=94=B9?=
=?UTF-8?q?=E6=8F=90=E5=8D=87=E4=BA=86=20OIDC=20=E8=AE=A4=E8=AF=81?=
=?UTF-8?q?=E7=9A=84=E5=AE=89=E5=85=A8=E6=80=A7=E5=92=8C=E7=81=B5=E6=B4=BB?=
=?UTF-8?q?=E6=80=A7=E3=80=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env.example.dex | 5 +++--
handlers/auth/dex.go | 47 +++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 49 insertions(+), 3 deletions(-)
diff --git a/.env.example.dex b/.env.example.dex
index fb19cf8..3d4b64c 100644
--- a/.env.example.dex
+++ b/.env.example.dex
@@ -1,12 +1,13 @@
OIDC_ISSUER_URL=http://localhost:5556
OIDC_CLIENT_ID=excalidraw
OIDC_CLIENT_SECRET=excalidraw-secret
-OIDC_REDIRECT_URL=http://localhost:3002/auth/oidc/callback
+OIDC_REDIRECT_URL=http://localhost:3000/auth/oidc/callback
ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH='$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W'
ADMIN_EMAIL=admin@example.com
-ADMIN_USER_ID=admin1234
+ADMIN_USERNAME=admin # Optional
+ADMIN_USER_ID=admin1234 # Optional
JWT_SECRET=your_super_secret_jwt_string
diff --git a/handlers/auth/dex.go b/handlers/auth/dex.go
index 371e3a5..0202f29 100644
--- a/handlers/auth/dex.go
+++ b/handlers/auth/dex.go
@@ -2,6 +2,8 @@ package auth
import (
"context"
+ "crypto/rand"
+ "encoding/hex"
"excalidraw-complete/core"
"fmt"
"net/http"
@@ -68,7 +70,27 @@ func HandleOIDCLogin(w http.ResponseWriter, r *http.Request) {
return
}
- url := oidcOauthConfig.AuthCodeURL("random", oauth2.AccessTypeOffline)
+ // Generate random state
+ stateBytes := make([]byte, 16)
+ _, err := rand.Read(stateBytes)
+ if err != nil {
+ http.Error(w, "Failed to generate state for OIDC login", http.StatusInternalServerError)
+ return
+ }
+ state := hex.EncodeToString(stateBytes)
+
+ // Set state in a cookie
+ http.SetCookie(w, &http.Cookie{
+ Name: "oidc_state",
+ Value: state,
+ Path: "/",
+ Expires: time.Now().Add(10 * time.Minute), // 10 minutes expiry
+ HttpOnly: true,
+ Secure: r.Header.Get("X-Forwarded-Proto") == "https",
+ SameSite: http.SameSiteLaxMode,
+ })
+
+ url := oidcOauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline)
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}
@@ -78,6 +100,29 @@ func HandleOIDCCallback(w http.ResponseWriter, r *http.Request) {
return
}
+ // Verify state cookie
+ stateCookie, err := r.Cookie("oidc_state")
+ if err != nil {
+ http.Error(w, "State cookie not found", http.StatusBadRequest)
+ return
+ }
+
+ if r.URL.Query().Get("state") != stateCookie.Value {
+ http.Error(w, "Invalid state", http.StatusBadRequest)
+ return
+ }
+
+ // Clear state cookie
+ http.SetCookie(w, &http.Cookie{
+ Name: "oidc_state",
+ Value: "",
+ Path: "/",
+ Expires: time.Unix(0, 0),
+ HttpOnly: true,
+ Secure: r.Header.Get("X-Forwarded-Proto") == "https",
+ SameSite: http.SameSiteLaxMode,
+ })
+
code := r.FormValue("code")
if code == "" {
logrus.Error("no code in callback")
From 2e368455ab86878e8852619bb1e14163a3d3f8a3 Mon Sep 17 00:00:00 2001
From: Yuzhong Zhang <141388234+BetterAndBetterII@users.noreply.github.com>
Date: Mon, 18 Aug 2025 20:51:45 +0800
Subject: [PATCH 05/16] =?UTF-8?q?=E9=87=8D=E6=9E=84=E8=AE=A4=E8=AF=81?=
=?UTF-8?q?=E6=B5=81=E7=A8=8B=EF=BC=8C=E5=90=88=E5=B9=B6=20GitHub=20?=
=?UTF-8?q?=E5=92=8C=20OIDC=20=E7=99=BB=E5=BD=95=E8=B7=AF=E7=94=B1?=
=?UTF-8?q?=EF=BC=8C=E6=9B=B4=E6=96=B0=E5=9B=9E=E8=B0=83=20URL=EF=BC=8C?=
=?UTF-8?q?=E7=AE=80=E5=8C=96=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F=E9=85=8D?=
=?UTF-8?q?=E7=BD=AE=EF=BC=8C=E7=A7=BB=E9=99=A4=E6=97=A7=E7=9A=84=20Dex=20?=
=?UTF-8?q?=E7=9B=B8=E5=85=B3=E4=BB=A3=E7=A0=81=EF=BC=8C=E5=A2=9E=E5=BC=BA?=
=?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=8F=AF=E8=AF=BB=E6=80=A7=E5=92=8C=E7=BB=B4?=
=?UTF-8?q?=E6=8A=A4=E6=80=A7=E3=80=82=E6=9B=B4=E6=96=B0=E6=96=87=E6=A1=A3?=
=?UTF-8?q?=E4=BB=A5=E5=8F=8D=E6=98=A0=E6=96=B0=E7=9A=84=E8=AE=A4=E8=AF=81?=
=?UTF-8?q?=E6=B5=81=E7=A8=8B=E5=92=8C=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F?=
=?UTF-8?q?=E8=AE=BE=E7=BD=AE=E3=80=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.cursor/rules/plan.mdc | 6 +-
.env.example | 4 +-
.env.example.dex | 2 +-
README.md | 6 +-
README_zh.md | 6 +-
docker-compose.dex.yml | 4 +-
handlers/auth/auth.go | 376 ++++++++++++++++++++++++++++++++++++++++
handlers/auth/dex.go | 200 ---------------------
handlers/auth/github.go | 178 -------------------
main.go | 14 +-
10 files changed, 394 insertions(+), 402 deletions(-)
create mode 100644 handlers/auth/auth.go
delete mode 100644 handlers/auth/dex.go
delete mode 100644 handlers/auth/github.go
diff --git a/.cursor/rules/plan.mdc b/.cursor/rules/plan.mdc
index 37d1e41..9b29747 100644
--- a/.cursor/rules/plan.mdc
+++ b/.cursor/rules/plan.mdc
@@ -19,8 +19,8 @@ alwaysApply: true
- [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] `GET /auth/login`
+ - [x] `GET /auth/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 的逻辑。
@@ -29,7 +29,7 @@ alwaysApply: true
### 前端 (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.2**: 添加api层,实现点击按钮后跳转到后端 `/auth/login` 的逻辑。
- [x] **1.2.3**: 创建一个用于处理登录回调的组件/页面,能从 URL 中解析出 JWT。
- [x] **1.2.4**: 将获取到的 JWT 安全地存储在 `localStorage` 或 `sessionStorage` 中。
- [x] **1.2.5**: 创建一个全局 API 请求封装(如 Axios 拦截器),为所有请求自动附加 `Authorization` 头。
diff --git a/.env.example b/.env.example
index ff79f77..03f9454 100644
--- a/.env.example
+++ b/.env.example
@@ -1,13 +1,13 @@
# GitHub OAuth 配置
GITHUB_CLIENT_ID="xxxxxxxxxxxxxxxxxxx"
GITHUB_CLIENT_SECRET="xxxxxxxxxxxxxx"
-GITHUB_REDIRECT_URL="http://localhost:3002/auth/github/callback" # 或者你部署后的回调地址
+GITHUB_REDIRECT_URL="http://localhost:3002/auth/callback" # 或者你部署后的回调地址
# OIDC 配置
OIDC_ISSUER_URL="http://localhost:5556/.well-known/openid-configuration" # OIDC 提供商地址
OIDC_CLIENT_ID="excalidraw"
OIDC_CLIENT_SECRET="excalidraw-secret"
-OIDC_REDIRECT_URL="http://localhost:3002/auth/oidc/callback"
+OIDC_REDIRECT_URL="http://localhost:3002/auth/callback"
# JWT 配置
JWT_SECRET="YOUR_SUPER_SECRET_RANDOM_STRING"
diff --git a/.env.example.dex b/.env.example.dex
index 3d4b64c..5b03376 100644
--- a/.env.example.dex
+++ b/.env.example.dex
@@ -1,7 +1,7 @@
OIDC_ISSUER_URL=http://localhost:5556
OIDC_CLIENT_ID=excalidraw
OIDC_CLIENT_SECRET=excalidraw-secret
-OIDC_REDIRECT_URL=http://localhost:3000/auth/oidc/callback
+OIDC_REDIRECT_URL=http://localhost:3000/auth/callback
ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH='$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W'
diff --git a/README.md b/README.md
index 3c8f8b1..553728a 100644
--- a/README.md
+++ b/README.md
@@ -63,7 +63,7 @@ You must configure GitHub OAuth and a JWT secret for the application to function
- `GITHUB_CLIENT_ID`: Your GitHub OAuth App's Client ID.
- `GITHUB_CLIENT_SECRET`: Your GitHub OAuth App's Client Secret.
-- `GITHUB_REDIRECT_URL`: The callback URL. For local testing, this is `http://localhost:3002/auth/github/callback`.
+- `GITHUB_REDIRECT_URL`: The callback URL. For local testing, this is `http://localhost:3002/auth/callback`.
- `JWT_SECRET`: A strong, random string for signing session tokens. Generate one with `openssl rand -base64 32`.
- `OPENAI_API_KEY`: Your secret key from OpenAI.
- `OPENAI_BASE_URL`: (Optional) For using compatible APIs like Azure OpenAI.
@@ -97,7 +97,7 @@ Create a `.env` file in the project root and add the following, filling in your
# Get from https://github.com/settings/developers
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
-GITHUB_REDIRECT_URL=http://localhost:3002/auth/github/callback
+GITHUB_REDIRECT_URL=http://localhost:3002/auth/callback
# Generate with: openssl rand -base64 32
JWT_SECRET=your_super_secret_jwt_string
@@ -129,7 +129,7 @@ docker build -t excalidraw-complete -f excalidraw-complete.Dockerfile .
docker run -p 3002:3002 \
-e GITHUB_CLIENT_ID="your_id" \
-e GITHUB_CLIENT_SECRET="your_secret" \
- -e GITHUB_REDIRECT_URL="http://localhost:3002/auth/github/callback" \
+ -e GITHUB_REDIRECT_URL="http://localhost:3002/auth/callback" \
-e JWT_SECRET="your_jwt_secret" \
-e STORAGE_TYPE="sqlite" \
-e DATA_SOURCE_NAME="excalidraw.db" \
diff --git a/README_zh.md b/README_zh.md
index ae4c753..6e66c4e 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -63,7 +63,7 @@ docker compose up -d
- `GITHUB_CLIENT_ID`: 您的 GitHub OAuth App 的 Client ID。
- `GITHUB_CLIENT_SECRET`: 您的 GitHub OAuth App 的 Client Secret。
-- `GITHUB_REDIRECT_URL`: 回调 URL。对于本地测试,这是 `http://localhost:3002/auth/github/callback`。
+- `GITHUB_REDIRECT_URL`: 回调 URL。对于本地测试,这是 `http://localhost:3002/auth/callback`。
- `JWT_SECRET`: 用于签署会话令牌的强随机字符串。使用 `openssl rand -base64 32` 生成一个。
- `OPENAI_API_KEY`: 您在 OpenAI 的秘密密钥。
- `OPENAI_BASE_URL`: (可选) 用于使用兼容的 API,如 Azure OpenAI。
@@ -97,7 +97,7 @@ docker compose up -d
# 从 https://github.com/settings/developers 获取
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
-GITHUB_REDIRECT_URL=http://localhost:3002/auth/github/callback
+GITHUB_REDIRECT_URL=http://localhost:3002/auth/callback
# 使用以下命令生成: openssl rand -base64 32
JWT_SECRET=your_super_secret_jwt_string
@@ -129,7 +129,7 @@ docker build -t excalidraw-complete -f excalidraw-complete.Dockerfile .
docker run -p 3002:3002 \
-e GITHUB_CLIENT_ID="your_id" \
-e GITHUB_CLIENT_SECRET="your_secret" \
- -e GITHUB_REDIRECT_URL="http://localhost:3002/auth/github/callback" \
+ -e GITHUB_REDIRECT_URL="http://localhost:3002/auth/callback" \
-e JWT_SECRET="your_jwt_secret" \
-e STORAGE_TYPE="sqlite" \
-e DATA_SOURCE_NAME="excalidraw.db" \
diff --git a/docker-compose.dex.yml b/docker-compose.dex.yml
index 4233ec1..560c947 100644
--- a/docker-compose.dex.yml
+++ b/docker-compose.dex.yml
@@ -5,7 +5,7 @@ services:
image: busybox:latest
ports:
- "5556:5556" # Dex
- - "3002:3002" # Excalidraw
+ - "3004:3002" # Excalidraw
command: ["sleep", "infinity"]
networks:
- excalidraw-network
@@ -17,7 +17,7 @@ services:
volumes:
- ./config/dex.config.yaml:/etc/dex/config.yaml
environment:
- - OIDC_REDIRECT_URL=${OIDC_REDIRECT_URL:-http://localhost:3002/auth/oidc/callback}
+ - OIDC_REDIRECT_URL=${OIDC_REDIRECT_URL:-http://localhost:3002/auth/callback}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-excalidraw-secret}
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-excalidraw}
- OIDC_ISSUER=${OIDC_ISSUER:-http://localhost:5556}
diff --git a/handlers/auth/auth.go b/handlers/auth/auth.go
new file mode 100644
index 0000000..f79672b
--- /dev/null
+++ b/handlers/auth/auth.go
@@ -0,0 +1,376 @@
+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"
+
+ "encoding/hex"
+
+ "github.com/coreos/go-oidc/v3/oidc"
+)
+
+var (
+ loginHandler http.HandlerFunc
+ callbackHandler http.HandlerFunc
+)
+
+var (
+ githubOauthConfig *oauth2.Config
+ jwtSecret []byte
+
+ oidcOauthConfig *oauth2.Config
+ oidcProvider *oidc.Provider
+ verifier *oidc.IDTokenVerifier
+)
+
+// 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"`
+}
+
+// OIDCClaims represents the claims from OIDC token
+type OIDCClaims struct {
+ Email string `json:"email"`
+ Name string `json:"name"`
+ PreferredUsername string `json:"preferred_username"`
+ Picture string `json:"picture"`
+ Sub string `json:"sub"`
+}
+
+func InitAuth() {
+ oidcConfigured := os.Getenv("OIDC_ISSUER_URL") != "" && os.Getenv("OIDC_CLIENT_ID") != ""
+ githubConfigured := os.Getenv("GITHUB_CLIENT_ID") != "" && os.Getenv("GITHUB_CLIENT_SECRET") != ""
+
+ if oidcConfigured {
+ logrus.Info("Initializing OIDC authentication provider.")
+ initOIDC()
+ loginHandler = HandleOIDCLogin
+ callbackHandler = HandleOIDCCallback
+ } else if githubConfigured {
+ logrus.Info("Initializing GitHub authentication provider.")
+ initGitHub()
+ loginHandler = HandleGitHubLogin
+ callbackHandler = HandleGitHubCallback
+ } else {
+ logrus.Warn("No authentication provider configured.")
+ dummyHandler := func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "Authentication not configured", http.StatusInternalServerError)
+ }
+ loginHandler = dummyHandler
+ callbackHandler = dummyHandler
+ }
+
+ jwtSecret = []byte(os.Getenv("JWT_SECRET"))
+ if len(jwtSecret) == 0 {
+ logrus.Warn("JWT_SECRET is not set. Authentication will not work.")
+ }
+}
+
+func HandleLogin(w http.ResponseWriter, r *http.Request) {
+ if loginHandler != nil {
+ loginHandler(w, r)
+ } else {
+ http.Error(w, "Authentication not configured", http.StatusInternalServerError)
+ }
+}
+
+func HandleCallback(w http.ResponseWriter, r *http.Request) {
+ if callbackHandler != nil {
+ callbackHandler(w, r)
+ } else {
+ http.Error(w, "Authentication not configured", http.StatusInternalServerError)
+ }
+}
+
+func initGitHub() {
+ 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,
+ }
+
+ if githubOauthConfig.ClientID == "" || githubOauthConfig.ClientSecret == "" {
+ logrus.Warn("GitHub OAuth credentials are not set. Authentication routes will not work.")
+ }
+}
+
+func initOIDC() {
+ providerURL := os.Getenv("OIDC_ISSUER_URL")
+ clientID := os.Getenv("OIDC_CLIENT_ID")
+ clientSecret := os.Getenv("OIDC_CLIENT_SECRET")
+ redirectURL := os.Getenv("OIDC_REDIRECT_URL")
+
+ if providerURL == "" || clientID == "" || clientSecret == "" {
+ logrus.Warn("OIDC credentials are not set. OIDC authentication routes will not work.")
+ return
+ }
+
+ var err error
+ oidcProvider, err = oidc.NewProvider(context.Background(), providerURL)
+ if err != nil {
+ logrus.Errorf("Failed to create OIDC provider: %s", err.Error())
+ return
+ }
+
+ oidcOauthConfig = &oauth2.Config{
+ ClientID: clientID,
+ ClientSecret: clientSecret,
+ RedirectURL: redirectURL,
+ Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
+ Endpoint: oidcProvider.Endpoint(),
+ }
+
+ logrus.Info("OIDC provider initialized")
+
+ verifier = oidcProvider.Verifier(&oidc.Config{
+ ClientID: clientID,
+ })
+}
+
+// Init function is deprecated, use InitAuth instead
+func Init() {
+ initGitHub()
+
+ 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
+ }
+
+ 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
+ }
+
+ // Create user object using Subject instead of GitHubID
+ user := &core.User{
+ Subject: fmt.Sprintf("github:%d", 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 HandleOIDCLogin(w http.ResponseWriter, r *http.Request) {
+ if oidcOauthConfig == nil {
+ http.Error(w, "OIDC is not configured", http.StatusInternalServerError)
+ return
+ }
+
+ // Generate random state
+ stateBytes := make([]byte, 16)
+ _, err := rand.Read(stateBytes)
+ if err != nil {
+ http.Error(w, "Failed to generate state for OIDC login", http.StatusInternalServerError)
+ return
+ }
+ state := hex.EncodeToString(stateBytes)
+
+ // Set state in a cookie
+ http.SetCookie(w, &http.Cookie{
+ Name: "oidc_state",
+ Value: state,
+ Path: "/",
+ Expires: time.Now().Add(10 * time.Minute), // 10 minutes expiry
+ HttpOnly: true,
+ Secure: r.Header.Get("X-Forwarded-Proto") == "https",
+ SameSite: http.SameSiteLaxMode,
+ })
+
+ url := oidcOauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline)
+ http.Redirect(w, r, url, http.StatusTemporaryRedirect)
+}
+
+func HandleOIDCCallback(w http.ResponseWriter, r *http.Request) {
+ if oidcOauthConfig == nil {
+ http.Error(w, "OIDC is not configured", http.StatusInternalServerError)
+ return
+ }
+
+ code := r.FormValue("code")
+ if code == "" {
+ logrus.Error("no code in callback")
+ http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
+ return
+ }
+
+ token, err := oidcOauthConfig.Exchange(context.Background(), code)
+ if err != nil {
+ logrus.Errorf("failed to exchange token: %s", err.Error())
+ http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
+ return
+ }
+
+ rawIDToken, ok := token.Extra("id_token").(string)
+ if !ok {
+ logrus.Error("no id_token in token response")
+ http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
+ return
+ }
+
+ idToken, err := verifier.Verify(context.Background(), rawIDToken)
+ if err != nil {
+ logrus.Errorf("failed to verify ID token: %s", err.Error())
+ http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
+ return
+ }
+
+ var claims OIDCClaims
+ if err := idToken.Claims(&claims); err != nil {
+ logrus.Errorf("failed to extract claims from ID token: %s", err.Error())
+ http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
+ return
+ }
+
+ // Create user from OIDC claims
+ user := &core.User{
+ Subject: claims.Sub,
+ Login: claims.PreferredUsername,
+ Email: claims.Email,
+ AvatarURL: claims.Picture,
+ Name: claims.Name,
+ }
+
+ // If preferred_username is not available, use email
+ if user.Login == "" && user.Email != "" {
+ user.Login = user.Email
+ }
+
+ 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: user.Subject,
+ 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")
+}
diff --git a/handlers/auth/dex.go b/handlers/auth/dex.go
deleted file mode 100644
index 0202f29..0000000
--- a/handlers/auth/dex.go
+++ /dev/null
@@ -1,200 +0,0 @@
-package auth
-
-import (
- "context"
- "crypto/rand"
- "encoding/hex"
- "excalidraw-complete/core"
- "fmt"
- "net/http"
- "os"
- "time"
-
- "github.com/coreos/go-oidc/v3/oidc"
- "github.com/golang-jwt/jwt/v5"
- "github.com/sirupsen/logrus"
- "golang.org/x/oauth2"
-)
-
-var (
- oidcOauthConfig *oauth2.Config
- oidcProvider *oidc.Provider
- verifier *oidc.IDTokenVerifier
-)
-
-// OIDCClaims represents the claims from OIDC token
-type OIDCClaims struct {
- Email string `json:"email"`
- Name string `json:"name"`
- PreferredUsername string `json:"preferred_username"`
- Picture string `json:"picture"`
- Sub string `json:"sub"`
-}
-
-func InitDex() {
- providerURL := os.Getenv("OIDC_ISSUER_URL")
- clientID := os.Getenv("OIDC_CLIENT_ID")
- clientSecret := os.Getenv("OIDC_CLIENT_SECRET")
- redirectURL := os.Getenv("OIDC_REDIRECT_URL")
-
- if providerURL == "" || clientID == "" || clientSecret == "" {
- logrus.Warn("OIDC credentials are not set. OIDC authentication routes will not work.")
- return
- }
-
- var err error
- oidcProvider, err = oidc.NewProvider(context.Background(), providerURL)
- if err != nil {
- logrus.Errorf("Failed to create OIDC provider: %s", err.Error())
- return
- }
-
- oidcOauthConfig = &oauth2.Config{
- ClientID: clientID,
- ClientSecret: clientSecret,
- RedirectURL: redirectURL,
- Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
- Endpoint: oidcProvider.Endpoint(),
- }
-
- logrus.Info("OIDC provider initialized")
-
- verifier = oidcProvider.Verifier(&oidc.Config{
- ClientID: clientID,
- })
-}
-
-func HandleOIDCLogin(w http.ResponseWriter, r *http.Request) {
- if oidcOauthConfig == nil {
- http.Error(w, "OIDC is not configured", http.StatusInternalServerError)
- return
- }
-
- // Generate random state
- stateBytes := make([]byte, 16)
- _, err := rand.Read(stateBytes)
- if err != nil {
- http.Error(w, "Failed to generate state for OIDC login", http.StatusInternalServerError)
- return
- }
- state := hex.EncodeToString(stateBytes)
-
- // Set state in a cookie
- http.SetCookie(w, &http.Cookie{
- Name: "oidc_state",
- Value: state,
- Path: "/",
- Expires: time.Now().Add(10 * time.Minute), // 10 minutes expiry
- HttpOnly: true,
- Secure: r.Header.Get("X-Forwarded-Proto") == "https",
- SameSite: http.SameSiteLaxMode,
- })
-
- url := oidcOauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline)
- http.Redirect(w, r, url, http.StatusTemporaryRedirect)
-}
-
-func HandleOIDCCallback(w http.ResponseWriter, r *http.Request) {
- if oidcOauthConfig == nil {
- http.Error(w, "OIDC is not configured", http.StatusInternalServerError)
- return
- }
-
- // Verify state cookie
- stateCookie, err := r.Cookie("oidc_state")
- if err != nil {
- http.Error(w, "State cookie not found", http.StatusBadRequest)
- return
- }
-
- if r.URL.Query().Get("state") != stateCookie.Value {
- http.Error(w, "Invalid state", http.StatusBadRequest)
- return
- }
-
- // Clear state cookie
- http.SetCookie(w, &http.Cookie{
- Name: "oidc_state",
- Value: "",
- Path: "/",
- Expires: time.Unix(0, 0),
- HttpOnly: true,
- Secure: r.Header.Get("X-Forwarded-Proto") == "https",
- SameSite: http.SameSiteLaxMode,
- })
-
- code := r.FormValue("code")
- if code == "" {
- logrus.Error("no code in callback")
- http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
- return
- }
-
- token, err := oidcOauthConfig.Exchange(context.Background(), code)
- if err != nil {
- logrus.Errorf("failed to exchange token: %s", err.Error())
- http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
- return
- }
-
- rawIDToken, ok := token.Extra("id_token").(string)
- if !ok {
- logrus.Error("no id_token in token response")
- http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
- return
- }
-
- idToken, err := verifier.Verify(context.Background(), rawIDToken)
- if err != nil {
- logrus.Errorf("failed to verify ID token: %s", err.Error())
- http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
- return
- }
-
- var claims OIDCClaims
- if err := idToken.Claims(&claims); err != nil {
- logrus.Errorf("failed to extract claims from ID token: %s", err.Error())
- http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
- return
- }
-
- // Create user from OIDC claims
- user := &core.User{
- Subject: claims.Sub,
- Login: claims.PreferredUsername,
- Email: claims.Email,
- AvatarURL: claims.Picture,
- Name: claims.Name,
- }
-
- // If preferred_username is not available, use email
- if user.Login == "" && user.Email != "" {
- user.Login = user.Email
- }
-
- jwtToken, err := createOIDCJWT(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 createOIDCJWT(user *core.User) (string, error) {
- claims := AppClaims{
- RegisteredClaims: jwt.RegisteredClaims{
- Subject: user.Subject,
- 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)
-}
diff --git a/handlers/auth/github.go b/handlers/auth/github.go
deleted file mode 100644
index 4986b8a..0000000
--- a/handlers/auth/github.go
+++ /dev/null
@@ -1,178 +0,0 @@
-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
-)
-
-
-// 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
- }
-
- // Create user object using Subject instead of GitHubID
- user := &core.User{
- Subject: fmt.Sprintf("github:%d", 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: user.Subject,
- 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")
-}
diff --git a/main.go b/main.go
index e601d72..a1ddd10 100644
--- a/main.go
+++ b/main.go
@@ -166,14 +166,9 @@ func setupRouter(store stores.Store) *chi.Mux {
})
})
- r.Route("/auth/github", func(r chi.Router) {
- r.Get("/login", auth.HandleGitHubLogin)
- r.Get("/callback", auth.HandleGitHubCallback)
- })
-
- r.Route("/auth/oidc", func(r chi.Router) {
- r.Get("/login", auth.HandleOIDCLogin)
- r.Get("/callback", auth.HandleOIDCCallback)
+ r.Route("/auth", func(r chi.Router) {
+ r.Get("/login", auth.HandleLogin)
+ r.Get("/callback", auth.HandleCallback)
})
return r
@@ -311,8 +306,7 @@ func main() {
FullTimestamp: true,
})
- auth.Init()
- auth.InitDex()
+ auth.InitAuth()
openai.Init()
store := stores.GetStore()
From d27df30788bbfb8e5fe491d2bf448858e2dbc685 Mon Sep 17 00:00:00 2001
From: BetterAndBetterII <1176445047@qq.com>
Date: Mon, 18 Aug 2025 20:06:18 +0800
Subject: [PATCH 06/16] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20.env.example=20?=
=?UTF-8?q?=E6=96=87=E4=BB=B6=EF=BC=8C=E7=A7=BB=E9=99=A4=20OIDC=20?=
=?UTF-8?q?=E7=9B=B8=E5=85=B3=E9=85=8D=E7=BD=AE=EF=BC=8C=E7=AE=80=E5=8C=96?=
=?UTF-8?q?=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F=E8=AE=BE=E7=BD=AE=EF=BC=8C?=
=?UTF-8?q?=E4=BB=A5=E5=8F=8D=E6=98=A0=E6=9C=80=E6=96=B0=E7=9A=84=E8=AE=A4?=
=?UTF-8?q?=E8=AF=81=E6=B5=81=E7=A8=8B=E5=92=8C=E9=85=8D=E7=BD=AE=E8=A6=81?=
=?UTF-8?q?=E6=B1=82=E3=80=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env.example | 6 ------
1 file changed, 6 deletions(-)
diff --git a/.env.example b/.env.example
index 03f9454..4def7be 100644
--- a/.env.example
+++ b/.env.example
@@ -3,12 +3,6 @@ GITHUB_CLIENT_ID="xxxxxxxxxxxxxxxxxxx"
GITHUB_CLIENT_SECRET="xxxxxxxxxxxxxx"
GITHUB_REDIRECT_URL="http://localhost:3002/auth/callback" # 或者你部署后的回调地址
-# OIDC 配置
-OIDC_ISSUER_URL="http://localhost:5556/.well-known/openid-configuration" # OIDC 提供商地址
-OIDC_CLIENT_ID="excalidraw"
-OIDC_CLIENT_SECRET="excalidraw-secret"
-OIDC_REDIRECT_URL="http://localhost:3002/auth/callback"
-
# JWT 配置
JWT_SECRET="YOUR_SUPER_SECRET_RANDOM_STRING"
From 6fb8f694970e44347f2bfe2032d8e7a06dc170e3 Mon Sep 17 00:00:00 2001
From: BetterAndBetterII <1176445047@qq.com>
Date: Mon, 18 Aug 2025 20:06:55 +0800
Subject: [PATCH 07/16] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=9E=B6=E6=9E=84?=
=?UTF-8?q?=E6=96=87=E6=A1=A3=E5=92=8C=E6=94=B9=E9=80=A0=E8=AE=A1=E5=88=92?=
=?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=8C=E6=B8=85=E7=90=86=E4=B8=8D=E5=86=8D?=
=?UTF-8?q?=E9=9C=80=E8=A6=81=E7=9A=84=E6=96=87=E4=BB=B6=EF=BC=8C=E4=BB=A5?=
=?UTF-8?q?=E7=AE=80=E5=8C=96=E9=A1=B9=E7=9B=AE=E7=BB=93=E6=9E=84=E5=92=8C?=
=?UTF-8?q?=E6=96=87=E6=A1=A3=E7=AE=A1=E7=90=86=E3=80=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.cursor/rules/arch.mdc | 220 ---------------------------------------
.cursor/rules/plan.mdc | 108 -------------------
ARCHITECTURE.md | 214 -------------------------------------
PROJECT_REFACTOR_PLAN.md | 106 -------------------
4 files changed, 648 deletions(-)
delete mode 100644 .cursor/rules/arch.mdc
delete mode 100644 .cursor/rules/plan.mdc
delete mode 100644 ARCHITECTURE.md
delete mode 100644 PROJECT_REFACTOR_PLAN.md
diff --git a/.cursor/rules/arch.mdc b/.cursor/rules/arch.mdc
deleted file mode 100644
index 495a519..0000000
--- a/.cursor/rules/arch.mdc
+++ /dev/null
@@ -1,220 +0,0 @@
----
-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 画布 (`` 组件)。
- - 管理整个应用的状态(如图形元素、应用状态如当前工具、缩放等)。
- - 处理用户输入事件。
- - 初始化并集成协作模块 (`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)
-
-
-`
\ No newline at end of file
diff --git a/.cursor/rules/plan.mdc b/.cursor/rules/plan.mdc
deleted file mode 100644
index 9b29747..0000000
--- a/.cursor/rules/plan.mdc
+++ /dev/null
@@ -1,108 +0,0 @@
----
-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/login`
- - [x] `GET /auth/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/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密钥。
\ No newline at end of file
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
deleted file mode 100644
index cc0e44b..0000000
--- a/ARCHITECTURE.md
+++ /dev/null
@@ -1,214 +0,0 @@
-# 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 画布 (`` 组件)。
- - 管理整个应用的状态(如图形元素、应用状态如当前工具、缩放等)。
- - 处理用户输入事件。
- - 初始化并集成协作模块 (`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)
-
-`
\ No newline at end of file
diff --git a/PROJECT_REFACTOR_PLAN.md b/PROJECT_REFACTOR_PLAN.md
deleted file mode 100644
index a6ac9b7..0000000
--- a/PROJECT_REFACTOR_PLAN.md
+++ /dev/null
@@ -1,106 +0,0 @@
-# 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密钥。
\ No newline at end of file
From 90452a472edf0b5c1c333fc340c564ec3b581eb4 Mon Sep 17 00:00:00 2001
From: BetterAndBetterII <1176445047@qq.com>
Date: Mon, 18 Aug 2025 12:16:50 +0000
Subject: [PATCH 08/16] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20docker-compose=20?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE=EF=BC=8C=E4=BF=AE=E6=AD=A3=20Excalidraw=20?=
=?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=9A=84=E7=AB=AF=E5=8F=A3=E6=98=A0=E5=B0=84?=
=?UTF-8?q?=EF=BC=8C=E5=B9=B6=E5=B0=86=E9=95=9C=E5=83=8F=E7=89=88=E6=9C=AC?=
=?UTF-8?q?=E4=BB=8E=20latest=20=E6=9B=B4=E6=94=B9=E4=B8=BA=20main?=
=?UTF-8?q?=EF=BC=8C=E4=BB=A5=E7=A1=AE=E4=BF=9D=E4=BD=BF=E7=94=A8=E6=9C=80?=
=?UTF-8?q?=E6=96=B0=E7=9A=84=E7=A8=B3=E5=AE=9A=E7=89=88=E6=9C=AC=E3=80=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docker-compose.dex.yml | 4 ++--
docker-compose.yml | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/docker-compose.dex.yml b/docker-compose.dex.yml
index 560c947..08d9fa9 100644
--- a/docker-compose.dex.yml
+++ b/docker-compose.dex.yml
@@ -5,7 +5,7 @@ services:
image: busybox:latest
ports:
- "5556:5556" # Dex
- - "3004:3002" # Excalidraw
+ - "3002:3002" # Excalidraw
command: ["sleep", "infinity"]
networks:
- excalidraw-network
@@ -35,7 +35,7 @@ services:
network_mode: service:netpod
excalidraw:
- image: ghcr.io/betterandbetterii/excalidraw-full:dex-oidc
+ image: ghcr.io/betterandbetterii/excalidraw-full:main
volumes:
- ./data:/root/data
- ./excalidraw.db:/root/excalidraw.db:Z
diff --git a/docker-compose.yml b/docker-compose.yml
index dd8f8d1..b011bce 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,7 +2,7 @@ version: '3.8'
services:
excalidraw:
- image: ghcr.io/betterandbetterii/excalidraw-full:latest
+ image: ghcr.io/betterandbetterii/excalidraw-full:main
ports:
- "3002:3002"
volumes:
From 3f10e749aa5a64a75a06395b78a8e275027f2ec4 Mon Sep 17 00:00:00 2001
From: BetterAndBetterII <1176445047@qq.com>
Date: Mon, 18 Aug 2025 12:24:17 +0000
Subject: [PATCH 09/16] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20docker-compose=20?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE=EF=BC=8C=E5=B0=86=20Excalidraw=20=E6=9C=8D?=
=?UTF-8?q?=E5=8A=A1=E7=9A=84=E9=95=9C=E5=83=8F=E7=89=88=E6=9C=AC=E4=BB=8E?=
=?UTF-8?q?=20main=20=E6=9B=B4=E6=94=B9=E4=B8=BA=20latest=EF=BC=8C?=
=?UTF-8?q?=E4=BB=A5=E7=A1=AE=E4=BF=9D=E4=BD=BF=E7=94=A8=E6=9C=80=E6=96=B0?=
=?UTF-8?q?=E7=9A=84=E9=95=9C=E5=83=8F=E7=89=88=E6=9C=AC=E3=80=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docker-compose.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docker-compose.yml b/docker-compose.yml
index b011bce..dd8f8d1 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,7 +2,7 @@ version: '3.8'
services:
excalidraw:
- image: ghcr.io/betterandbetterii/excalidraw-full:main
+ image: ghcr.io/betterandbetterii/excalidraw-full:latest
ports:
- "3002:3002"
volumes:
From 959f04400cb5d90efb7ce28b99263032a31628f2 Mon Sep 17 00:00:00 2001
From: BetterAndBetterII <1176445047@qq.com>
Date: Mon, 18 Aug 2025 20:31:28 +0800
Subject: [PATCH 10/16] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20.gitignore=20?=
=?UTF-8?q?=E6=96=87=E4=BB=B6=E4=BB=A5=E5=BF=BD=E7=95=A5=20.htpasswd=20?=
=?UTF-8?q?=E6=96=87=E4=BB=B6=EF=BC=8C=E6=B7=BB=E5=8A=A0=E7=AE=80=E5=8D=95?=
=?UTF-8?q?=E5=AF=86=E7=A0=81=E8=AE=A4=E8=AF=81=E7=9A=84=E4=BD=BF=E7=94=A8?=
=?UTF-8?q?=E8=AF=B4=E6=98=8E=E5=88=B0=20README=20=E6=96=87=E4=BB=B6?=
=?UTF-8?q?=E4=B8=AD=EF=BC=8C=E5=8C=85=E5=90=AB=E7=A4=BA=E4=BE=8B=E5=91=BD?=
=?UTF-8?q?=E4=BB=A4=E5=92=8C=E9=85=8D=E7=BD=AE=E6=AD=A5=E9=AA=A4=EF=BC=8C?=
=?UTF-8?q?=E5=A2=9E=E5=BC=BA=E6=96=87=E6=A1=A3=E7=9A=84=E5=8F=AF=E8=AF=BB?=
=?UTF-8?q?=E6=80=A7=E5=92=8C=E5=AE=9E=E7=94=A8=E6=80=A7=E3=80=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 1 +
README.md | 27 +++++++++++++++++++++++++++
README_zh.md | 26 ++++++++++++++++++++++++++
3 files changed, 54 insertions(+)
diff --git a/.gitignore b/.gitignore
index 7c31eca..a9e4618 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,4 @@ node_modules
*.db
data/
.idea
+.htpasswd
diff --git a/README.md b/README.md
index 553728a..ca820b5 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,33 @@ docker compose up -d
The server will start, and you can access the application at `http://localhost:3002`.
+
+
+
+Use Simple Password Authentication(Dex OIDC)
+
+```bash
+# Example for Linux
+git clone https://github.com/BetterAndBetterII/excalidraw-full.git
+cd excalidraw-full
+mv .env.example .env
+touch ./excalidraw.db # IMPORTANT: Initialize the SQLite DB, OTHERWISE IT WILL NOT START
+docker compose -f docker-compose.dex.yml up -d
+```
+
+Change your password in `.env` file.
+
+```bash
+# apt install apache2-utils
+# Generate the password hash
+echo YOUR_NEW_PASSWORD | htpasswd -BinC 10 admin | cut -d: -f2 > .htpasswd
+# Update your .env file
+sed -i 's/ADMIN_PASSWORD_HASH=.*/ADMIN_PASSWORD_HASH=$(cat .htpasswd)/' .env
+```
+
+
+
+
## Configuration
Configuration is managed via environment variables. For a full template, see the `.env.example` section below.
diff --git a/README_zh.md b/README_zh.md
index 6e66c4e..53e7a4f 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -53,6 +53,32 @@ docker compose up -d
服务器将启动,您可以在 `http://localhost:3002` 访问该应用。
+
+
+使用简单密码认证(Dex OIDC)
+
+```bash
+# 示例
+git clone https://github.com/BetterAndBetterII/excalidraw-full.git
+cd excalidraw-full
+mv .env.example .env
+touch ./excalidraw.db # 重要:初始化 SQLite 数据库,否则无法启动
+docker compose -f docker-compose.dex.yml up -d
+```
+
+修改 `.env` 文件中的密码。
+
+```bash
+# apt install apache2-utils
+# 生成密码哈希
+echo YOUR_NEW_PASSWORD | htpasswd -BinC 10 admin | cut -d: -f2 > .htpasswd
+# 更新 .env 文件
+sed -i 's/ADMIN_PASSWORD_HASH=.*/ADMIN_PASSWORD_HASH=$(cat .htpasswd)/' .env
+```
+
+
+
+
## 配置
配置通过环境变量进行管理。有关完整模板,请参阅下面的 `.env.example` 部分。
From 5ce2d8604d5b597b6b7d7c6c88b4b67af4672842 Mon Sep 17 00:00:00 2001
From: Yuzhong Zhang <141388234+BetterAndBetterII@users.noreply.github.com>
Date: Mon, 18 Aug 2025 20:34:44 +0800
Subject: [PATCH 11/16] Update handlers/auth/auth.go
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
---
handlers/auth/auth.go | 1 +
1 file changed, 1 insertion(+)
diff --git a/handlers/auth/auth.go b/handlers/auth/auth.go
index f79672b..c59bda9 100644
--- a/handlers/auth/auth.go
+++ b/handlers/auth/auth.go
@@ -40,6 +40,7 @@ var (
type AppClaims struct {
jwt.RegisteredClaims
Login string `json:"login"`
+ Email string `json:"email,omitempty"`
AvatarURL string `json:"avatarUrl"`
Name string `json:"name"`
}
From e218aeb6b34db4dd0dee7c99adfbc219df31e6a7 Mon Sep 17 00:00:00 2001
From: BetterAndBetterII <1176445047@qq.com>
Date: Mon, 18 Aug 2025 12:35:27 +0000
Subject: [PATCH 12/16] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20README=20=E6=96=87?=
=?UTF-8?q?=E4=BB=B6=E4=B8=AD=E7=9A=84=E5=AF=86=E7=A0=81=E5=93=88=E5=B8=8C?=
=?UTF-8?q?=E7=94=9F=E6=88=90=E5=91=BD=E4=BB=A4=EF=BC=8C=E4=BF=AE=E6=AD=A3?=
=?UTF-8?q?=20.env=20=E6=96=87=E4=BB=B6=E4=B8=AD=20ADMIN=5FPASSWORD=5FHASH?=
=?UTF-8?q?=20=E7=9A=84=E6=9B=B4=E6=96=B0=E6=96=B9=E5=BC=8F=EF=BC=8C?=
=?UTF-8?q?=E7=A1=AE=E4=BF=9D=E5=AF=86=E7=A0=81=E5=93=88=E5=B8=8C=E8=A2=AB?=
=?UTF-8?q?=E6=AD=A3=E7=A1=AE=E5=BC=95=E7=94=A8=E3=80=82=E6=AD=A4=E6=9B=B4?=
=?UTF-8?q?=E6=94=B9=E6=8F=90=E9=AB=98=E4=BA=86=E6=96=87=E6=A1=A3=E7=9A=84?=
=?UTF-8?q?=E5=87=86=E7=A1=AE=E6=80=A7=E5=92=8C=E5=8F=AF=E7=94=A8=E6=80=A7?=
=?UTF-8?q?=E3=80=82?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env.example.dex | 3 +--
README.md | 2 +-
README_zh.md | 2 +-
3 files changed, 3 insertions(+), 4 deletions(-)
diff --git a/.env.example.dex b/.env.example.dex
index 5b03376..c8e9bac 100644
--- a/.env.example.dex
+++ b/.env.example.dex
@@ -3,9 +3,8 @@ OIDC_CLIENT_ID=excalidraw
OIDC_CLIENT_SECRET=excalidraw-secret
OIDC_REDIRECT_URL=http://localhost:3000/auth/callback
-ADMIN_USERNAME=admin
-ADMIN_PASSWORD_HASH='$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W'
ADMIN_EMAIL=admin@example.com
+ADMIN_PASSWORD_HASH='$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W'
ADMIN_USERNAME=admin # Optional
ADMIN_USER_ID=admin1234 # Optional
diff --git a/README.md b/README.md
index ca820b5..d047c04 100644
--- a/README.md
+++ b/README.md
@@ -74,7 +74,7 @@ Change your password in `.env` file.
# Generate the password hash
echo YOUR_NEW_PASSWORD | htpasswd -BinC 10 admin | cut -d: -f2 > .htpasswd
# Update your .env file
-sed -i 's/ADMIN_PASSWORD_HASH=.*/ADMIN_PASSWORD_HASH=$(cat .htpasswd)/' .env
+sed -i "s|ADMIN_PASSWORD_HASH=.*|ADMIN_PASSWORD_HASH='$(cat .htpasswd)'|" .env
```
diff --git a/README_zh.md b/README_zh.md
index 53e7a4f..f232ceb 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -73,7 +73,7 @@ docker compose -f docker-compose.dex.yml up -d
# 生成密码哈希
echo YOUR_NEW_PASSWORD | htpasswd -BinC 10 admin | cut -d: -f2 > .htpasswd
# 更新 .env 文件
-sed -i 's/ADMIN_PASSWORD_HASH=.*/ADMIN_PASSWORD_HASH=$(cat .htpasswd)/' .env
+sed -i "s|ADMIN_PASSWORD_HASH=.*|ADMIN_PASSWORD_HASH='$(cat .htpasswd)'|" .env
```
From eba694750586db8ad664dee49ddfc27557b5aae5 Mon Sep 17 00:00:00 2001
From: Yuzhong Zhang <141388234+BetterAndBetterII@users.noreply.github.com>
Date: Mon, 18 Aug 2025 21:19:37 +0800
Subject: [PATCH 13/16] =?UTF-8?q?=E6=9B=B4=E6=96=B0head?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
excalidraw | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/excalidraw b/excalidraw
index b0248d8..e0d0e7b 160000
--- a/excalidraw
+++ b/excalidraw
@@ -1 +1 @@
-Subproject commit b0248d8c3067fd3fa807da4c3538486d6fa420f8
+Subproject commit e0d0e7be992942720c63e179556e7b553b02a4fa
From 020caa2fb6158d5f5ee02eb0db548292f4b78944 Mon Sep 17 00:00:00 2001
From: Yuzhong Zhang <141388234+BetterAndBetterII@users.noreply.github.com>
Date: Mon, 18 Aug 2025 21:32:55 +0800
Subject: [PATCH 14/16] update
---
excalidraw | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/excalidraw b/excalidraw
index e0d0e7b..d3741a5 160000
--- a/excalidraw
+++ b/excalidraw
@@ -1 +1 @@
-Subproject commit e0d0e7be992942720c63e179556e7b553b02a4fa
+Subproject commit d3741a52d44e06cb94da5f54ec44dccea67208fe
From 118109ca88de158f336affe88452774a43e999ff Mon Sep 17 00:00:00 2001
From: BetterAndBetterII <1176445047@qq.com>
Date: Mon, 18 Aug 2025 13:33:48 +0000
Subject: [PATCH 15/16] change image tag
---
docker-compose.dex.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docker-compose.dex.yml b/docker-compose.dex.yml
index 08d9fa9..3fdf446 100644
--- a/docker-compose.dex.yml
+++ b/docker-compose.dex.yml
@@ -35,7 +35,7 @@ services:
network_mode: service:netpod
excalidraw:
- image: ghcr.io/betterandbetterii/excalidraw-full:main
+ image: ghcr.io/betterandbetterii/excalidraw-full:latest
volumes:
- ./data:/root/data
- ./excalidraw.db:/root/excalidraw.db:Z
From c09d482355c47eee13944a78ddc14aebecd646c3 Mon Sep 17 00:00:00 2001
From: Yuzhong Zhang <141388234+BetterAndBetterII@users.noreply.github.com>
Date: Mon, 18 Aug 2025 21:35:02 +0800
Subject: [PATCH 16/16] update upstream
---
excalidraw | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/excalidraw b/excalidraw
index d3741a5..b5cca50 160000
--- a/excalidraw
+++ b/excalidraw
@@ -1 +1 @@
-Subproject commit d3741a52d44e06cb94da5f54ec44dccea67208fe
+Subproject commit b5cca508d480108457faa62fda06b9071c132d94