feat(server): add project sharing access control

This commit is contained in:
2026-07-10 14:46:18 +08:00
parent 60130b3d9b
commit 9d47cb9bbd
41 changed files with 3069 additions and 17 deletions
@@ -0,0 +1,31 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package handler
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"img_infinite_canvas/internal/logic"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
)
func deleteShareMemberHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.DeleteShareMemberRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := logic.NewDeleteShareMemberLogic(r.Context(), svcCtx)
resp, err := l.DeleteShareMember(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}
@@ -0,0 +1,31 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package handler
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"img_infinite_canvas/internal/logic"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
)
func getShareSettingsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ShareSettingsRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := logic.NewGetShareSettingsLogic(r.Context(), svcCtx)
resp, err := l.GetShareSettings(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}
@@ -0,0 +1,31 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package handler
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"img_infinite_canvas/internal/logic"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
)
func inviteShareMemberHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.InviteShareMemberRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := logic.NewInviteShareMemberLogic(r.Context(), svcCtx)
resp, err := l.InviteShareMember(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}
@@ -0,0 +1,31 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package handler
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"img_infinite_canvas/internal/logic"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
)
func listShareVisitorsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ShareSettingsRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := logic.NewListShareVisitorsLogic(r.Context(), svcCtx)
resp, err := l.ListShareVisitors(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}
@@ -0,0 +1,31 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package handler
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"img_infinite_canvas/internal/logic"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
)
func resolveShareHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ResolveShareRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := logic.NewResolveShareLogic(r.Context(), svcCtx)
resp, err := l.ResolveShare(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}
@@ -0,0 +1,31 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package handler
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"img_infinite_canvas/internal/logic"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
)
func revokeShareAccessHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ShareSettingsRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := logic.NewRevokeShareAccessLogic(r.Context(), svcCtx)
resp, err := l.RevokeShareAccess(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}
+40
View File
@@ -254,6 +254,41 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/projects/:id/nodes/:nodeId/text-extraction/async",
Handler: extractNodeTextAsyncHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/projects/:id/sharing",
Handler: getShareSettingsHandler(serverCtx),
},
{
Method: http.MethodDelete,
Path: "/projects/:id/sharing/access",
Handler: revokeShareAccessHandler(serverCtx),
},
{
Method: http.MethodPut,
Path: "/projects/:id/sharing/link",
Handler: updateShareLinkHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/projects/:id/sharing/members",
Handler: inviteShareMemberHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/projects/:id/sharing/members/:memberId",
Handler: updateShareMemberHandler(serverCtx),
},
{
Method: http.MethodDelete,
Path: "/projects/:id/sharing/members/:memberId",
Handler: deleteShareMemberHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/projects/:id/sharing/visitors",
Handler: listShareVisitorsHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/projects/:id/title",
@@ -269,6 +304,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/projects/batch-delete",
Handler: deleteProjectsHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/shares/:shareId",
Handler: resolveShareHandler(serverCtx),
},
},
rest.WithPrefix("/api"),
)
@@ -0,0 +1,31 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package handler
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"img_infinite_canvas/internal/logic"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
)
func updateShareLinkHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpdateShareLinkRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := logic.NewUpdateShareLinkLogic(r.Context(), svcCtx)
resp, err := l.UpdateShareLink(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}
@@ -0,0 +1,31 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package handler
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"img_infinite_canvas/internal/logic"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
)
func updateShareMemberHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpdateShareMemberRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := logic.NewUpdateShareMemberLogic(r.Context(), svcCtx)
resp, err := l.UpdateShareMember(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}
@@ -2,20 +2,35 @@ package handler
import (
"context"
"errors"
"net/http"
"strings"
"img_infinite_canvas/internal/domain/design"
authmodule "img_infinite_canvas/internal/modules/auth"
sharingmodule "img_infinite_canvas/internal/modules/sharing"
)
type bearerAuthenticator interface {
UserIDFromBearer(ctx context.Context, authorization string) (string, bool)
}
func UserContextMiddleware(authenticator bearerAuthenticator) func(http.HandlerFunc) http.HandlerFunc {
type profileAuthenticator interface {
GetProfile(ctx context.Context, userID string) (authmodule.User, error)
}
type shareRequestAuthorizer interface {
ResolveAccess(ctx context.Context, shareID string, actor sharingmodule.Actor) (sharingmodule.Access, error)
}
func UserContextMiddleware(authenticator bearerAuthenticator, shareAuthorizers ...shareRequestAuthorizer) func(http.HandlerFunc) http.HandlerFunc {
var shareAuthorizer shareRequestAuthorizer
if len(shareAuthorizers) > 0 {
shareAuthorizer = shareAuthorizers[0]
}
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-Id")
userID := ""
authenticated := false
if authenticator != nil {
if tokenUserID, ok := userIDFromRequestAuth(r.Context(), r, authenticator); ok {
@@ -23,11 +38,54 @@ func UserContextMiddleware(authenticator bearerAuthenticator) func(http.HandlerF
authenticated = true
}
}
if requiresAuthenticatedUser(r) && !authenticated {
actor := sharingmodule.Actor{Authenticated: authenticated, UserID: userID}
if authenticated && requestUsesShareAccess(r) {
if profiles, ok := authenticator.(profileAuthenticator); ok {
profile, err := profiles.GetProfile(r.Context(), userID)
if err != nil {
writeAuthRequired(w)
return
}
actor.Email = profile.Email
actor.CountryCode = profile.CountryCode
actor.Phone = profile.Phone
actor.Name = profile.Name
actor.AvatarURL = profile.AvatarURL
}
}
ctx := sharingmodule.ContextWithActor(r.Context(), actor)
ctx = design.ContextWithUserID(ctx, userID)
shareAuthorized := false
shareID := strings.TrimSpace(r.Header.Get("X-Share-Id"))
projectID := shareProjectID(r)
if shareID != "" && r.Method == http.MethodDelete && r.URL.Path == "/api/assets" {
writeShareAccessError(w, sharingmodule.ErrForbidden)
return
}
if shareID != "" && projectID != "" && shareAuthorizer != nil {
access, err := shareAuthorizer.ResolveAccess(ctx, shareID, actor)
if err != nil {
writeShareAccessError(w, err)
return
}
if access.ProjectID != projectID {
writeShareAccessError(w, sharingmodule.ErrNotFound)
return
}
if !access.Allows(requiredShareCapability(r)) {
writeShareAccessError(w, sharingmodule.ErrForbidden)
return
}
ctx = sharingmodule.ContextWithAccess(ctx, access)
ctx = design.ContextWithUserID(ctx, access.OwnerID)
shareAuthorized = true
}
if requiresAuthenticatedUser(r) && !authenticated && !shareAuthorized {
writeAuthRequired(w)
return
}
ctx := design.ContextWithUserID(r.Context(), userID)
next(w, r.WithContext(ctx))
}
}
@@ -39,16 +97,20 @@ func userIDFromRequestAuth(ctx context.Context, r *http.Request, authenticator b
func requiresAuthenticatedUser(r *http.Request) bool {
path := r.URL.Path
if strings.HasPrefix(path, "/v1/") {
return true
}
if !strings.HasPrefix(path, "/api/") {
return false
}
if strings.HasPrefix(path, "/api/auth/") || path == "/api/auth/options" || path == "/api/health" || path == "/api/dashboard" {
if strings.HasPrefix(path, "/api/auth/") ||
strings.HasPrefix(path, "/api/shares/") ||
path == "/api/auth/options" ||
path == "/api/health" ||
path == "/api/dashboard" {
return false
}
return strings.HasPrefix(path, "/api/projects") ||
strings.HasPrefix(path, "/api/account") ||
strings.HasPrefix(path, "/api/assets") ||
strings.HasPrefix(path, "/api/generator/")
return true
}
func writeAuthRequired(w http.ResponseWriter) {
@@ -57,10 +119,76 @@ func writeAuthRequired(w http.ResponseWriter) {
_, _ = w.Write([]byte(`{"error":"login required","errorCode":"auth.login_required","code":401}`))
}
func requestUsesShareAccess(r *http.Request) bool {
return strings.TrimSpace(r.Header.Get("X-Share-Id")) != "" || strings.HasPrefix(r.URL.Path, "/api/shares/")
}
func shareProjectID(r *http.Request) string {
const prefix = "/api/projects/"
if strings.HasPrefix(r.URL.Path, prefix) {
remainder := strings.TrimPrefix(r.URL.Path, prefix)
projectID, _, _ := strings.Cut(remainder, "/")
projectID = strings.TrimSpace(projectID)
if projectID == "" || projectID == "async" || projectID == "batch-delete" {
return ""
}
return projectID
}
if r.URL.Path == "/api/generator/tasks" {
if headerProjectID := strings.TrimSpace(r.Header.Get("X-Share-Project-Id")); headerProjectID != "" {
return headerProjectID
}
return strings.TrimSpace(r.URL.Query().Get("project_id"))
}
return ""
}
func requiredShareCapability(r *http.Request) sharingmodule.Capability {
path := r.URL.Path
if path == "/api/generator/tasks" {
return sharingmodule.CapabilityEdit
}
if strings.Contains(path, "/sharing") || (r.Method == http.MethodDelete && shareProjectRoot(path)) {
return sharingmodule.CapabilityManage
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
return sharingmodule.CapabilityEdit
}
if strings.HasSuffix(path, "/agent/ws") {
return sharingmodule.CapabilityEdit
}
if strings.Contains(path, "/agent/") || strings.HasSuffix(path, "/history") || strings.HasSuffix(path, "/events") {
return sharingmodule.CapabilityViewChat
}
return sharingmodule.CapabilityViewCanvas
}
func shareProjectRoot(path string) bool {
remainder := strings.TrimPrefix(path, "/api/projects/")
return remainder != path && !strings.Contains(remainder, "/")
}
func writeShareAccessError(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
switch {
case errors.Is(err, sharingmodule.ErrLoginRequired):
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"login required for edit access","errorCode":"share.login_required","code":401}`))
case errors.Is(err, sharingmodule.ErrForbidden):
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"share access forbidden","errorCode":"share.forbidden","code":403}`))
case errors.Is(err, sharingmodule.ErrNotFound):
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"share not found","errorCode":"share.not_found","code":404}`))
default:
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"share authorization failed","errorCode":"share.internal","code":500}`))
}
}
func LegacyUserContextMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-Id")
ctx := design.ContextWithUserID(r.Context(), userID)
ctx := design.ContextWithUserID(r.Context(), "")
next(w, r.WithContext(ctx))
}
}
@@ -7,6 +7,7 @@ import (
"testing"
"img_infinite_canvas/internal/domain/design"
sharingmodule "img_infinite_canvas/internal/modules/sharing"
)
type fakeBearerAuthenticator struct{}
@@ -18,6 +19,15 @@ func (fakeBearerAuthenticator) UserIDFromBearer(_ context.Context, authorization
return "", false
}
type fakeShareAuthorizer struct {
access sharingmodule.Access
err error
}
func (f fakeShareAuthorizer) ResolveAccess(_ context.Context, _ string, _ sharingmodule.Actor) (sharingmodule.Access, error) {
return f.access, f.err
}
func TestUserContextMiddlewareUsesAuthorizationHeader(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/projects", nil)
req.Header.Set("Authorization", "Bearer valid")
@@ -58,3 +68,133 @@ func TestUserContextMiddlewareIgnoresLegacyTokenHeaderAndCookie(t *testing.T) {
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
}
}
func TestUserContextMiddlewareIgnoresSpoofedUserIDOnPublicRoute(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/dashboard", nil)
req.Header.Set("X-User-Id", "91cd197b-8255-4172-9981-77391fd38f33")
rec := httptest.NewRecorder()
UserContextMiddleware(fakeBearerAuthenticator{})(func(w http.ResponseWriter, r *http.Request) {
if got := design.UserIDFromContext(r.Context()); got != design.DefaultUserID {
t.Fatalf("expected spoofed identity to be ignored, got %s", got)
}
w.WriteHeader(http.StatusNoContent)
})(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected public route to continue, got %d", rec.Code)
}
}
func TestUserContextMiddlewareAuthorizesAnonymousCanvasShare(t *testing.T) {
ownerID := "d1d2fbbb-97bb-4406-8d0b-ec814cc65092"
authorizer := fakeShareAuthorizer{access: sharingmodule.Access{
ProjectID: "project-1",
OwnerID: ownerID,
Permission: sharingmodule.PermissionCanvas,
Capabilities: sharingmodule.PermissionCanvas.Capabilities(),
}}
req := httptest.NewRequest(http.MethodGet, "/api/projects/project-1", nil)
req.Header.Set("X-Share-Id", "XhRewKnS4it7X9kEMFLcdVJentg")
rec := httptest.NewRecorder()
UserContextMiddleware(fakeBearerAuthenticator{}, authorizer)(func(w http.ResponseWriter, r *http.Request) {
if got := design.UserIDFromContext(r.Context()); got != ownerID {
t.Fatalf("expected owner-scoped repository context, got %s", got)
}
if _, ok := sharingmodule.AccessFromContext(r.Context()); !ok {
t.Fatal("expected effective share access in context")
}
w.WriteHeader(http.StatusNoContent)
})(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected shared read to continue, got %d", rec.Code)
}
}
func TestUserContextMiddlewareDeniesCanvasShareChatHistory(t *testing.T) {
authorizer := fakeShareAuthorizer{access: sharingmodule.Access{
ProjectID: "project-1",
OwnerID: "d1d2fbbb-97bb-4406-8d0b-ec814cc65092",
Permission: sharingmodule.PermissionCanvas,
Capabilities: sharingmodule.PermissionCanvas.Capabilities(),
}}
req := httptest.NewRequest(http.MethodGet, "/api/projects/project-1/history", nil)
req.Header.Set("X-Share-Id", "XhRewKnS4it7X9kEMFLcdVJentg")
rec := httptest.NewRecorder()
called := false
UserContextMiddleware(fakeBearerAuthenticator{}, authorizer)(func(http.ResponseWriter, *http.Request) {
called = true
})(rec, req)
if called {
t.Fatal("expected canvas-only chat history to be blocked")
}
if rec.Code != http.StatusForbidden {
t.Fatalf("expected forbidden, got %d", rec.Code)
}
}
func TestUserContextMiddlewareDoesNotLetHeaderOverridePathProject(t *testing.T) {
authorizer := fakeShareAuthorizer{access: sharingmodule.Access{
ProjectID: "shared-project",
OwnerID: "d1d2fbbb-97bb-4406-8d0b-ec814cc65092",
Permission: sharingmodule.PermissionEditor,
Capabilities: sharingmodule.PermissionEditor.Capabilities(),
}}
req := httptest.NewRequest(http.MethodGet, "/api/projects/other-project", nil)
req.Header.Set("X-Share-Id", "XhRewKnS4it7X9kEMFLcdVJentg")
req.Header.Set("X-Share-Project-Id", "shared-project")
rec := httptest.NewRecorder()
called := false
UserContextMiddleware(fakeBearerAuthenticator{}, authorizer)(func(http.ResponseWriter, *http.Request) {
called = true
})(rec, req)
if called {
t.Fatal("expected mismatched path project to be blocked")
}
if rec.Code != http.StatusNotFound {
t.Fatalf("expected not found, got %d", rec.Code)
}
}
func TestUserContextMiddlewareRequiresEditorForGeneratorTasks(t *testing.T) {
authorizer := fakeShareAuthorizer{access: sharingmodule.Access{
ProjectID: "project-1",
OwnerID: "d1d2fbbb-97bb-4406-8d0b-ec814cc65092",
Permission: sharingmodule.PermissionViewer,
Capabilities: sharingmodule.PermissionViewer.Capabilities(),
}}
req := httptest.NewRequest(http.MethodGet, "/api/generator/tasks?project_id=project-1&task_id=task-1", nil)
req.Header.Set("X-Share-Id", "XhRewKnS4it7X9kEMFLcdVJentg")
rec := httptest.NewRecorder()
UserContextMiddleware(fakeBearerAuthenticator{}, authorizer)(func(http.ResponseWriter, *http.Request) {
t.Fatal("expected viewer generator access to be blocked")
})(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected forbidden, got %d", rec.Code)
}
}
func TestUserContextMiddlewareProtectsV1Routes(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/generator/tasks", nil)
rec := httptest.NewRecorder()
called := false
UserContextMiddleware(fakeBearerAuthenticator{})(func(http.ResponseWriter, *http.Request) {
called = true
})(rec, req)
if called {
t.Fatal("expected v1 route to require authentication")
}
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected unauthorized, got %d", rec.Code)
}
}