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,39 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package logic
import (
"context"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type DeleteShareMemberLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewDeleteShareMemberLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteShareMemberLogic {
return &DeleteShareMemberLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *DeleteShareMemberLogic) DeleteShareMember(req *types.DeleteShareMemberRequest) (resp *types.ShareSettingsResponse, err error) {
project, err := ownedShareProject(l.ctx, l.svcCtx, req.Id)
if err != nil {
return nil, err
}
settings, err := l.svcCtx.ShareService.DeleteMember(l.ctx, project.ID, project.UserID, req.MemberId)
if err != nil {
return nil, err
}
return toShareSettingsResponse(l.ctx, l.svcCtx, project, settings), nil
}
@@ -6,6 +6,7 @@ package logic
import (
"context"
sharingmodule "img_infinite_canvas/internal/modules/sharing"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
@@ -31,6 +32,9 @@ func (l *GetProjectLogic) GetProject(req *types.ProjectIdRequest) (resp *types.P
if err != nil {
return nil, err
}
if access, ok := sharingmodule.AccessFromContext(l.ctx); ok {
project = filterSharedProject(project, access)
}
return toProjectResponse(project), nil
}
@@ -0,0 +1,39 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package logic
import (
"context"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type GetShareSettingsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetShareSettingsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetShareSettingsLogic {
return &GetShareSettingsLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetShareSettingsLogic) GetShareSettings(req *types.ShareSettingsRequest) (resp *types.ShareSettingsResponse, err error) {
project, err := ownedShareProject(l.ctx, l.svcCtx, req.Id)
if err != nil {
return nil, err
}
settings, err := l.svcCtx.ShareService.Settings(l.ctx, project.ID, project.UserID)
if err != nil {
return nil, err
}
return toShareSettingsResponse(l.ctx, l.svcCtx, project, settings), nil
}
@@ -0,0 +1,48 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package logic
import (
"context"
sharingmodule "img_infinite_canvas/internal/modules/sharing"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type InviteShareMemberLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewInviteShareMemberLogic(ctx context.Context, svcCtx *svc.ServiceContext) *InviteShareMemberLogic {
return &InviteShareMemberLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *InviteShareMemberLogic) InviteShareMember(req *types.InviteShareMemberRequest) (resp *types.ShareSettingsResponse, err error) {
permission, err := sharingmodule.ParseMemberPermission(req.Permission)
if err != nil {
return nil, err
}
project, err := ownedShareProject(l.ctx, l.svcCtx, req.Id)
if err != nil {
return nil, err
}
owner, err := verifiedShareOwner(l.ctx, l.svcCtx, project)
if err != nil {
return nil, err
}
settings, err := l.svcCtx.ShareService.Invite(l.ctx, project.ID, owner, req.Identifier, permission)
if err != nil {
return nil, err
}
return toShareSettingsResponse(l.ctx, l.svcCtx, project, settings), nil
}
@@ -0,0 +1,39 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package logic
import (
"context"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ListShareVisitorsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListShareVisitorsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListShareVisitorsLogic {
return &ListShareVisitorsLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *ListShareVisitorsLogic) ListShareVisitors(req *types.ShareSettingsRequest) (resp *types.ShareVisitorsResponse, err error) {
project, err := ownedShareProject(l.ctx, l.svcCtx, req.Id)
if err != nil {
return nil, err
}
visitors, err := l.svcCtx.ShareService.Visitors(l.ctx, project.ID, project.UserID)
if err != nil {
return nil, err
}
return toShareVisitorsResponse(l.ctx, l.svcCtx, visitors), nil
}
+13 -5
View File
@@ -9,6 +9,7 @@ import (
"time"
"img_infinite_canvas/internal/domain/design"
sharingmodule "img_infinite_canvas/internal/modules/sharing"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
@@ -37,10 +38,17 @@ func (l *QueryProjectLogic) QueryProject(req *types.QueryProjectRequest) (resp *
return nil, err
}
return toQueryProjectResponse(project), nil
permission := "OWNER"
visibility := "PRIVATE"
if access, ok := sharingmodule.AccessFromContext(l.ctx); ok {
permission = strings.ToUpper(string(access.Permission))
visibility = "SHARED"
project = filterSharedProject(project, access)
}
return toQueryProjectResponse(project, permission, visibility), nil
}
func toQueryProjectResponse(project design.Project) *types.QueryProjectResponse {
func toQueryProjectResponse(project design.Project, permission string, visibility string) *types.QueryProjectResponse {
covers := queryProjectCoverList(project)
updatedAt := formatLovartTime(project.UpdatedAt)
return &types.QueryProjectResponse{
@@ -63,9 +71,9 @@ func toQueryProjectResponse(project design.Project) *types.QueryProjectResponse
UniqId: project.SnapshotID,
ParentUniqId: "",
},
EffectivePermission: "OWNER",
EffectivePermission: permission,
CopyTaskStatus: nil,
Visibility: "PRIVATE",
Visibility: visibility,
CreateTime: updatedAt,
UpdateTime: updatedAt,
},
@@ -78,7 +86,7 @@ func toQueryProjectResponse(project design.Project) *types.QueryProjectResponse
Version: project.Version,
SnapshotId: nil,
ItemId: nil,
Permission: "OWNER",
Permission: permission,
},
}
}
@@ -0,0 +1,57 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package logic
import (
"context"
"time"
"img_infinite_canvas/internal/domain/design"
sharingmodule "img_infinite_canvas/internal/modules/sharing"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ResolveShareLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewResolveShareLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ResolveShareLogic {
return &ResolveShareLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *ResolveShareLogic) ResolveShare(req *types.ResolveShareRequest) (resp *types.ResolveShareResponse, err error) {
actor := sharingmodule.ActorFromContext(l.ctx)
access, err := l.svcCtx.ShareService.ResolveAccess(l.ctx, req.ShareId, actor)
if err != nil {
return nil, err
}
ownerCtx := design.ContextWithUserID(l.ctx, access.OwnerID)
project, err := l.svcCtx.DesignService.GetProject(ownerCtx, access.ProjectID)
if err != nil {
return nil, err
}
if err := l.svcCtx.ShareService.RecordVisit(l.ctx, access, actor, req.VisitorId); err != nil {
l.Errorf("record share visit: %v", err)
}
project = filterSharedProject(project, access)
updatedAt := project.UpdatedAt
if updatedAt.IsZero() {
updatedAt = time.Now().UTC()
}
return &types.ResolveShareResponse{
ShareId: access.ShareID,
Project: toAPIProject(project),
Access: toShareAccess(access),
UpdatedAt: formatTime(updatedAt),
}, nil
}
@@ -0,0 +1,39 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package logic
import (
"context"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type RevokeShareAccessLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewRevokeShareAccessLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RevokeShareAccessLogic {
return &RevokeShareAccessLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *RevokeShareAccessLogic) RevokeShareAccess(req *types.ShareSettingsRequest) (resp *types.ShareSettingsResponse, err error) {
project, err := ownedShareProject(l.ctx, l.svcCtx, req.Id)
if err != nil {
return nil, err
}
settings, err := l.svcCtx.ShareService.RevokeAll(l.ctx, project.ID, project.UserID)
if err != nil {
return nil, err
}
return toShareSettingsResponse(l.ctx, l.svcCtx, project, settings), nil
}
+152
View File
@@ -0,0 +1,152 @@
package logic
import (
"context"
"errors"
"strings"
"img_infinite_canvas/internal/domain/design"
sharingmodule "img_infinite_canvas/internal/modules/sharing"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
)
func ownedShareProject(ctx context.Context, svcCtx *svc.ServiceContext, projectID string) (design.Project, error) {
return svcCtx.DesignService.GetProject(ctx, strings.TrimSpace(projectID))
}
func verifiedShareOwner(ctx context.Context, svcCtx *svc.ServiceContext, project design.Project) (sharingmodule.Actor, error) {
if svcCtx.AuthService == nil {
return sharingmodule.Actor{}, errors.New("auth service is required for share management")
}
profile, err := svcCtx.AuthService.GetProfile(ctx, project.UserID)
if err != nil {
return sharingmodule.Actor{}, err
}
return sharingmodule.Actor{
Authenticated: true,
UserID: project.UserID,
Email: profile.Email,
CountryCode: profile.CountryCode,
Phone: profile.Phone,
Name: profile.Name,
AvatarURL: profile.AvatarURL,
}, nil
}
func toShareSettingsResponse(ctx context.Context, svcCtx *svc.ServiceContext, project design.Project, settings sharingmodule.Settings) *types.ShareSettingsResponse {
owner := types.SharePerson{
Id: project.UserID,
UserId: project.UserID,
Name: "Owner",
Permission: string(sharingmodule.PermissionOwner),
}
ownerActor, ownerErr := verifiedShareOwner(ctx, svcCtx, project)
if ownerErr == nil {
owner.Name = firstShareIdentifier(ownerActor.Name, owner.Name)
owner.AvatarUrl = ownerActor.AvatarURL
owner.Identifier = firstShareIdentifier(ownerActor.Email, displayPhone(ownerActor.CountryCode, ownerActor.Phone))
}
visibleMembers := filterShareMembers(settings.Members, ownerActor)
members := make([]types.SharePerson, 0, len(visibleMembers))
for _, member := range visibleMembers {
members = append(members, types.SharePerson{
Id: member.ID,
Name: member.Identifier,
Identifier: member.Identifier,
Permission: string(member.Permission),
CreatedAt: formatTime(member.CreatedAt),
})
}
return &types.ShareSettingsResponse{Settings: types.ShareSettings{
ProjectId: project.ID,
ShareId: settings.ShareID,
LinkPermission: string(settings.LinkPermission),
Owner: owner,
Members: members,
VisitorCount: settings.VisitorCount,
UpdatedAt: formatTime(settings.UpdatedAt),
}}
}
func filterShareMembers(members []sharingmodule.Member, owner sharingmodule.Actor) []sharingmodule.Member {
if owner.UserID == "" {
return members
}
filtered := make([]sharingmodule.Member, 0, len(members))
for _, member := range members {
if sharingmodule.ActorOwnsIdentifier(owner, member.Identifier) {
continue
}
filtered = append(filtered, member)
}
return filtered
}
func toShareVisitorsResponse(ctx context.Context, svcCtx *svc.ServiceContext, visits []sharingmodule.VisitRecord) *types.ShareVisitorsResponse {
items := make([]types.ShareVisitor, 0, len(visits))
for _, visit := range visits {
item := types.ShareVisitor{
Id: visit.ID,
UserId: visit.UserID,
Name: "Anonymous visitor",
VisitCount: visit.VisitCount,
LastSeenAt: formatTime(visit.LastSeenAt),
}
if visit.UserID != "" && svcCtx.AuthService != nil {
if profile, err := svcCtx.AuthService.GetProfile(ctx, visit.UserID); err == nil {
item.Name = firstShareIdentifier(profile.Name, profile.Email, displayPhone(profile.CountryCode, profile.Phone), "Visitor")
item.Identifier = firstShareIdentifier(profile.Email, displayPhone(profile.CountryCode, profile.Phone))
item.AvatarUrl = profile.AvatarURL
}
}
items = append(items, item)
}
return &types.ShareVisitorsResponse{Visitors: items}
}
func toShareAccess(access sharingmodule.Access) types.ShareAccess {
return types.ShareAccess{
Permission: string(access.Permission),
Source: access.Source,
Authenticated: access.Authenticated,
IsOwner: access.IsOwner,
Capabilities: types.ShareCapabilities{
CanViewCanvas: access.Capabilities.CanViewCanvas,
CanViewChat: access.Capabilities.CanViewChat,
CanEdit: access.Capabilities.CanEdit,
CanManage: access.Capabilities.CanManage,
},
}
}
func filterSharedProject(project design.Project, access sharingmodule.Access) design.Project {
project.UserID = ""
if !access.Capabilities.CanViewChat {
project.Brief = ""
project.LastThreadID = ""
project.Messages = []design.Message{}
}
return project
}
func firstShareIdentifier(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func displayPhone(countryCode string, phone string) string {
phone = strings.TrimSpace(phone)
if phone == "" {
return ""
}
countryCode = strings.TrimPrefix(strings.TrimSpace(countryCode), "+")
if countryCode == "" || strings.HasPrefix(phone, "+") {
return phone
}
return "+" + countryCode + phone
}
@@ -0,0 +1,90 @@
package logic
import (
"context"
"errors"
"testing"
"time"
"img_infinite_canvas/internal/domain/design"
sharingmodule "img_infinite_canvas/internal/modules/sharing"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
)
func TestFilterSharedProjectRedactsCanvasOnlyData(t *testing.T) {
project := design.Project{
ID: "project-1",
UserID: "91cd197b-8255-4172-9981-77391fd38f33",
Brief: "private brief",
LastThreadID: "thread-1",
Messages: []design.Message{{
ID: "message-1",
Content: "private chat",
CreatedAt: time.Now(),
}},
}
canvasAccess := sharingmodule.Access{
Permission: sharingmodule.PermissionCanvas,
Capabilities: sharingmodule.PermissionCanvas.Capabilities(),
}
filtered := filterSharedProject(project, canvasAccess)
if filtered.UserID != "" || filtered.Brief != "" || filtered.LastThreadID != "" || len(filtered.Messages) != 0 {
t.Fatalf("canvas-only response leaked private fields: %+v", filtered)
}
if project.UserID == "" || len(project.Messages) == 0 {
t.Fatal("filter must not mutate the source project")
}
}
func TestSubmitGeneratorTaskRejectsShareProjectMismatch(t *testing.T) {
ctx := sharingmodule.ContextWithAccess(context.Background(), sharingmodule.Access{ProjectID: "shared-project"})
logic := NewSubmitGeneratorTaskLogic(ctx, &svc.ServiceContext{})
_, err := logic.SubmitGeneratorTask(&types.GeneratorTaskSubmitRequest{ProjectId: "other-project"})
if !errors.Is(err, sharingmodule.ErrNotFound) {
t.Fatalf("expected share project mismatch to be rejected, got %v", err)
}
}
func TestFilterSharedProjectKeepsViewerMessages(t *testing.T) {
project := design.Project{
UserID: "91cd197b-8255-4172-9981-77391fd38f33",
Messages: []design.Message{{
ID: "message-1",
Content: "visible chat",
}},
}
viewerAccess := sharingmodule.Access{
Permission: sharingmodule.PermissionViewer,
Capabilities: sharingmodule.PermissionViewer.Capabilities(),
}
filtered := filterSharedProject(project, viewerAccess)
if filtered.UserID != "" {
t.Fatal("shared response must not expose the owner id")
}
if len(filtered.Messages) != 1 || filtered.Messages[0].Content != "visible chat" {
t.Fatalf("viewer should retain chat messages: %+v", filtered.Messages)
}
}
func TestFilterShareMembersRemovesOwnerIdentifiers(t *testing.T) {
owner := sharingmodule.Actor{
Authenticated: true,
UserID: "91cd197b-8255-4172-9981-77391fd38f33",
Email: "owner@example.com",
CountryCode: "+86",
Phone: "13800138000",
}
members := []sharingmodule.Member{
{ID: "email", Identifier: "OWNER@EXAMPLE.COM", Permission: sharingmodule.PermissionEditor},
{ID: "phone", Identifier: "+86 138-0013-8000", Permission: sharingmodule.PermissionViewer},
{ID: "other", Identifier: "member@example.com", Permission: sharingmodule.PermissionViewer},
}
filtered := filterShareMembers(members, owner)
if len(filtered) != 1 || filtered[0].ID != "other" {
t.Fatalf("expected only the non-owner member, got %+v", filtered)
}
}
@@ -7,6 +7,7 @@ import (
"context"
"img_infinite_canvas/internal/domain/design"
sharingmodule "img_infinite_canvas/internal/modules/sharing"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
@@ -28,6 +29,9 @@ func NewSubmitGeneratorTaskLogic(ctx context.Context, svcCtx *svc.ServiceContext
}
func (l *SubmitGeneratorTaskLogic) SubmitGeneratorTask(req *types.GeneratorTaskSubmitRequest) (resp *types.GeneratorTaskSubmitResponse, err error) {
if access, ok := sharingmodule.AccessFromContext(l.ctx); ok && req.ProjectId != access.ProjectID {
return nil, sharingmodule.ErrNotFound
}
task, err := l.svcCtx.DesignService.SubmitGeneratorTask(l.ctx, design.GeneratorTaskSubmitRequest{
CID: req.Cid,
ProjectID: req.ProjectId,
@@ -0,0 +1,44 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package logic
import (
"context"
sharingmodule "img_infinite_canvas/internal/modules/sharing"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type UpdateShareLinkLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUpdateShareLinkLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateShareLinkLogic {
return &UpdateShareLinkLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UpdateShareLinkLogic) UpdateShareLink(req *types.UpdateShareLinkRequest) (resp *types.ShareSettingsResponse, err error) {
permission, err := sharingmodule.ParseLinkPermission(req.Permission)
if err != nil {
return nil, err
}
project, err := ownedShareProject(l.ctx, l.svcCtx, req.Id)
if err != nil {
return nil, err
}
settings, err := l.svcCtx.ShareService.UpdateLink(l.ctx, project.ID, project.UserID, permission)
if err != nil {
return nil, err
}
return toShareSettingsResponse(l.ctx, l.svcCtx, project, settings), nil
}
@@ -0,0 +1,44 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package logic
import (
"context"
sharingmodule "img_infinite_canvas/internal/modules/sharing"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type UpdateShareMemberLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUpdateShareMemberLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateShareMemberLogic {
return &UpdateShareMemberLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UpdateShareMemberLogic) UpdateShareMember(req *types.UpdateShareMemberRequest) (resp *types.ShareSettingsResponse, err error) {
permission, err := sharingmodule.ParseMemberPermission(req.Permission)
if err != nil {
return nil, err
}
project, err := ownedShareProject(l.ctx, l.svcCtx, req.Id)
if err != nil {
return nil, err
}
settings, err := l.svcCtx.ShareService.UpdateMember(l.ctx, project.ID, project.UserID, req.MemberId, permission)
if err != nil {
return nil, err
}
return toShareSettingsResponse(l.ctx, l.svcCtx, project, settings), nil
}