diff --git a/server/API.md b/server/API.md index 7da9ec4..20f195e 100644 --- a/server/API.md +++ b/server/API.md @@ -204,6 +204,59 @@ Renames the infinite canvas project and updates `updatedAt`. Deletes the project record. PostgreSQL cascades canvas nodes, chat messages, history, and connections; after the database delete succeeds, image assets referenced by the canvas and generated-file history are queued for asynchronous OSS deletion. +## Sharing and access control + +Sharing uses a separate authorization policy from project ownership. Effective access is resolved in this order: owner, explicit email/phone member, then link permission. + +- `canvas`: canvas document only; messages and agent endpoints are unavailable. +- `viewer`: canvas and chat history are readable, but every mutation is denied. +- `editor`: viewer access plus canvas/agent mutations; authentication is mandatory. +- `owner`: editor access plus share administration and project deletion. + +Share IDs are 27-character, case-sensitive base62 values generated from cryptographic randomness. The database stores a SHA-256 lookup hash and AES-GCM ciphertext rather than a plaintext link credential. Supply `SHARING_ENCRYPTION_SECRET` through the deployment secret manager; when omitted, local development falls back to `Auth.TokenSecret`. + +All management endpoints require `Authorization: Bearer ` and verify project ownership: + +- `GET /api/projects/:id/sharing` returns link permission, authorized people, owner information, and visitor count. +- `PUT /api/projects/:id/sharing/link` accepts `private`, `canvas`, `viewer`, or `editor`. +- `POST /api/projects/:id/sharing/members` accepts an email/phone `identifier` and a `canvas`, `viewer`, or `editor` permission. Identifiers are normalized and unique per project; inviting the owner returns `share.self_invite`, while inviting an existing member returns `share.member_exists` (both HTTP `409`). +- `PATCH /api/projects/:id/sharing/members/:memberId` changes a member permission. +- `DELETE /api/projects/:id/sharing/members/:memberId` removes one member. +- `GET /api/projects/:id/sharing/visitors` lists deduplicated visitors and visit counts. +- `DELETE /api/projects/:id/sharing/access` removes the policy, members, and visitors; the old link immediately fails on the next request. + +Example invite: + +```json +{ + "identifier": "editor@example.com", + "permission": "editor" +} +``` + +`GET /api/shares/:shareId` is the public bootstrap. `Authorization` is optional for `canvas`/`viewer`, mandatory for `editor`, and used to resolve explicit member access. Clients send a random installation-scoped `X-Share-Visitor` value for visitor deduplication. + +```json +{ + "shareId": "XhRewKnS4it7X9kEMFLcdVJentg", + "project": { "id": "project-id", "nodes": [], "messages": [] }, + "access": { + "permission": "canvas", + "source": "link", + "authenticated": false, + "isOwner": false, + "capabilities": { + "canViewCanvas": true, + "canViewChat": false, + "canEdit": false, + "canManage": false + } + } +} +``` + +Subsequent shared project requests send `X-Share-Id: `. Middleware re-resolves access, binds it to the exact project ID, and only then creates an internal owner-scoped repository context. Client-supplied `X-User-Id` is ignored. + ## Agent Chat `POST /api/projects/:id/agent/chat` diff --git a/server/README.md b/server/README.md index f645c8a..1018d45 100644 --- a/server/README.md +++ b/server/README.md @@ -17,6 +17,7 @@ go-zero backend for the Moteva-style infinite canvas MVP. - Optional Redis cache, default memory cache - Asynq job queue for generation, OCR, layer merge, and asset cleanup workers - Image asset upload URLs for memory, MinIO, AWS S3, Cloudflare R2, and Aliyun OSS +- Encrypted sharing links, email/phone grants, visitor tracking, and server-enforced canvas/viewer/editor/owner capabilities ## Configuration @@ -44,6 +45,8 @@ JobQueue: MaxRetry: 3 TimeoutSeconds: 900 ShutdownTimeoutSeconds: 30 +Sharing: + EncryptionSecretEnv: SHARING_ENCRYPTION_SECRET Agent: Driver: pi Text: diff --git a/server/img_infinite_canvas.api b/server/img_infinite_canvas.api index b603838..c86427f 100644 --- a/server/img_infinite_canvas.api +++ b/server/img_infinite_canvas.api @@ -865,6 +865,97 @@ type DeleteAssetResponse { Deleted bool `json:"deleted"` } +type SharePerson { + Id string `json:"id"` + UserId string `json:"userId,optional"` + Name string `json:"name,optional"` + Identifier string `json:"identifier,optional"` + AvatarUrl string `json:"avatarUrl,optional"` + Permission string `json:"permission"` + CreatedAt string `json:"createdAt,optional"` +} + +type ShareVisitor { + Id string `json:"id"` + UserId string `json:"userId,optional"` + Name string `json:"name"` + Identifier string `json:"identifier,optional"` + AvatarUrl string `json:"avatarUrl,optional"` + VisitCount int64 `json:"visitCount"` + LastSeenAt string `json:"lastSeenAt"` +} + +type ShareCapabilities { + CanViewCanvas bool `json:"canViewCanvas"` + CanViewChat bool `json:"canViewChat"` + CanEdit bool `json:"canEdit"` + CanManage bool `json:"canManage"` +} + +type ShareAccess { + Permission string `json:"permission"` + Source string `json:"source"` + Authenticated bool `json:"authenticated"` + IsOwner bool `json:"isOwner"` + Capabilities ShareCapabilities `json:"capabilities"` +} + +type ShareSettings { + ProjectId string `json:"projectId"` + ShareId string `json:"shareId,optional"` + LinkPermission string `json:"linkPermission"` + Owner SharePerson `json:"owner"` + Members []SharePerson `json:"members"` + VisitorCount int64 `json:"visitorCount"` + UpdatedAt string `json:"updatedAt,optional"` +} + +type ShareSettingsRequest { + Id string `path:"id"` +} + +type ShareSettingsResponse { + Settings ShareSettings `json:"settings"` +} + +type UpdateShareLinkRequest { + Id string `path:"id"` + Permission string `json:"permission"` +} + +type InviteShareMemberRequest { + Id string `path:"id"` + Identifier string `json:"identifier"` + Permission string `json:"permission"` +} + +type UpdateShareMemberRequest { + Id string `path:"id"` + MemberId string `path:"memberId"` + Permission string `json:"permission"` +} + +type DeleteShareMemberRequest { + Id string `path:"id"` + MemberId string `path:"memberId"` +} + +type ShareVisitorsResponse { + Visitors []ShareVisitor `json:"visitors"` +} + +type ResolveShareRequest { + ShareId string `path:"shareId"` + VisitorId string `header:"X-Share-Visitor,optional"` +} + +type ResolveShareResponse { + ShareId string `json:"shareId"` + Project Project `json:"project"` + Access ShareAccess `json:"access"` + UpdatedAt string `json:"updatedAt"` +} + @server ( prefix: /api ) @@ -1021,5 +1112,29 @@ service img_infinite_canvas-api { @handler deleteAsset delete /assets (DeleteAssetRequest) returns (DeleteAssetResponse) + + @handler getShareSettings + get /projects/:id/sharing (ShareSettingsRequest) returns (ShareSettingsResponse) + + @handler updateShareLink + put /projects/:id/sharing/link (UpdateShareLinkRequest) returns (ShareSettingsResponse) + + @handler inviteShareMember + post /projects/:id/sharing/members (InviteShareMemberRequest) returns (ShareSettingsResponse) + + @handler updateShareMember + patch /projects/:id/sharing/members/:memberId (UpdateShareMemberRequest) returns (ShareSettingsResponse) + + @handler deleteShareMember + delete /projects/:id/sharing/members/:memberId (DeleteShareMemberRequest) returns (ShareSettingsResponse) + + @handler listShareVisitors + get /projects/:id/sharing/visitors (ShareSettingsRequest) returns (ShareVisitorsResponse) + + @handler revokeShareAccess + delete /projects/:id/sharing/access (ShareSettingsRequest) returns (ShareSettingsResponse) + + @handler resolveShare + get /shares/:shareId (ResolveShareRequest) returns (ResolveShareResponse) } diff --git a/server/img_infinite_canvas.go b/server/img_infinite_canvas.go index 3ac0797..9535b77 100644 --- a/server/img_infinite_canvas.go +++ b/server/img_infinite_canvas.go @@ -59,7 +59,7 @@ func main() { server := rest.MustNewServer(c.RestConf) defer server.Stop() - server.Use(handler.UserContextMiddleware(ctx.AuthService)) + server.Use(handler.UserContextMiddleware(ctx.AuthService, ctx.ShareService)) handler.RegisterHandlers(server, ctx) handler.RegisterV1Handlers(server, ctx) diff --git a/server/internal/config/config.go b/server/internal/config/config.go index f10edc3..fb482a2 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -13,10 +13,16 @@ type Config struct { Realtime RealtimeConfig Outbox OutboxConfig Auth AuthConfig + Sharing SharingConfig Agent AgentConfig ObjectStorage ObjectStorageConfig } +type SharingConfig struct { + EncryptionSecret string `json:",optional"` + EncryptionSecretEnv string `json:",default=SHARING_ENCRYPTION_SECRET"` +} + type StorageConfig struct { Driver string `json:",default=memory"` DataSource string `json:",optional"` diff --git a/server/internal/errorx/error.go b/server/internal/errorx/error.go index 05107bf..c50d35c 100644 --- a/server/internal/errorx/error.go +++ b/server/internal/errorx/error.go @@ -7,6 +7,7 @@ import ( "img_infinite_canvas/internal/domain/design" authmodule "img_infinite_canvas/internal/modules/auth" + sharingmodule "img_infinite_canvas/internal/modules/sharing" ) type CodeError struct { @@ -47,6 +48,18 @@ func Handle(ctx context.Context, err error) (int, any) { return http.StatusBadRequest, map[string]any{"error": err.Error(), "errorCode": "auth.human_verification_failed", "code": http.StatusBadRequest} case errors.Is(err, authmodule.ErrProviderNotReady): return http.StatusBadRequest, map[string]any{"error": err.Error(), "errorCode": "auth.provider_not_ready", "code": http.StatusBadRequest} + case errors.Is(err, sharingmodule.ErrLoginRequired): + return http.StatusUnauthorized, map[string]any{"error": err.Error(), "errorCode": "share.login_required", "code": http.StatusUnauthorized} + case errors.Is(err, sharingmodule.ErrSelfInvite): + return http.StatusConflict, map[string]any{"error": err.Error(), "errorCode": "share.self_invite", "code": http.StatusConflict} + case errors.Is(err, sharingmodule.ErrMemberExists): + return http.StatusConflict, map[string]any{"error": err.Error(), "errorCode": "share.member_exists", "code": http.StatusConflict} + case errors.Is(err, sharingmodule.ErrForbidden): + return http.StatusForbidden, map[string]any{"error": err.Error(), "errorCode": "share.forbidden", "code": http.StatusForbidden} + case errors.Is(err, sharingmodule.ErrNotFound): + return http.StatusNotFound, map[string]any{"error": sharingmodule.ErrNotFound.Error(), "errorCode": "share.not_found", "code": http.StatusNotFound} + case errors.Is(err, sharingmodule.ErrInvalidInput): + return http.StatusBadRequest, map[string]any{"error": err.Error(), "errorCode": "share.invalid_input", "code": http.StatusBadRequest} case errors.Is(err, context.Canceled): return http.StatusRequestTimeout, map[string]any{"error": "request canceled", "code": http.StatusRequestTimeout} default: diff --git a/server/internal/errorx/error_test.go b/server/internal/errorx/error_test.go new file mode 100644 index 0000000..676e99a --- /dev/null +++ b/server/internal/errorx/error_test.go @@ -0,0 +1,36 @@ +package errorx + +import ( + "context" + "net/http" + "testing" + + sharingmodule "img_infinite_canvas/internal/modules/sharing" +) + +func TestHandleShareInviteConflicts(t *testing.T) { + tests := []struct { + name string + err error + errorCode string + }{ + {name: "self invite", err: sharingmodule.ErrSelfInvite, errorCode: "share.self_invite"}, + {name: "member exists", err: sharingmodule.ErrMemberExists, errorCode: "share.member_exists"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + status, body := Handle(context.Background(), tt.err) + if status != http.StatusConflict { + t.Fatalf("expected status %d, got %d", http.StatusConflict, status) + } + payload, ok := body.(map[string]any) + if !ok { + t.Fatalf("expected map response, got %T", body) + } + if payload["errorCode"] != tt.errorCode { + t.Fatalf("expected error code %q, got %v", tt.errorCode, payload["errorCode"]) + } + }) + } +} diff --git a/server/internal/handler/delete_share_member_handler.go b/server/internal/handler/delete_share_member_handler.go new file mode 100644 index 0000000..096403b --- /dev/null +++ b/server/internal/handler/delete_share_member_handler.go @@ -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) + } + } +} diff --git a/server/internal/handler/get_share_settings_handler.go b/server/internal/handler/get_share_settings_handler.go new file mode 100644 index 0000000..6069d45 --- /dev/null +++ b/server/internal/handler/get_share_settings_handler.go @@ -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) + } + } +} diff --git a/server/internal/handler/invite_share_member_handler.go b/server/internal/handler/invite_share_member_handler.go new file mode 100644 index 0000000..916ff15 --- /dev/null +++ b/server/internal/handler/invite_share_member_handler.go @@ -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) + } + } +} diff --git a/server/internal/handler/list_share_visitors_handler.go b/server/internal/handler/list_share_visitors_handler.go new file mode 100644 index 0000000..ffd1605 --- /dev/null +++ b/server/internal/handler/list_share_visitors_handler.go @@ -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) + } + } +} diff --git a/server/internal/handler/resolve_share_handler.go b/server/internal/handler/resolve_share_handler.go new file mode 100644 index 0000000..77c76c8 --- /dev/null +++ b/server/internal/handler/resolve_share_handler.go @@ -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) + } + } +} diff --git a/server/internal/handler/revoke_share_access_handler.go b/server/internal/handler/revoke_share_access_handler.go new file mode 100644 index 0000000..80ed7c5 --- /dev/null +++ b/server/internal/handler/revoke_share_access_handler.go @@ -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) + } + } +} diff --git a/server/internal/handler/routes.go b/server/internal/handler/routes.go index 22c2840..5ffd7f3 100644 --- a/server/internal/handler/routes.go +++ b/server/internal/handler/routes.go @@ -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"), ) diff --git a/server/internal/handler/update_share_link_handler.go b/server/internal/handler/update_share_link_handler.go new file mode 100644 index 0000000..bdd885a --- /dev/null +++ b/server/internal/handler/update_share_link_handler.go @@ -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) + } + } +} diff --git a/server/internal/handler/update_share_member_handler.go b/server/internal/handler/update_share_member_handler.go new file mode 100644 index 0000000..b637457 --- /dev/null +++ b/server/internal/handler/update_share_member_handler.go @@ -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) + } + } +} diff --git a/server/internal/handler/user_context_middleware.go b/server/internal/handler/user_context_middleware.go index 5c4fa7b..df62e93 100644 --- a/server/internal/handler/user_context_middleware.go +++ b/server/internal/handler/user_context_middleware.go @@ -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)) } } diff --git a/server/internal/handler/user_context_middleware_test.go b/server/internal/handler/user_context_middleware_test.go index 2472b8a..6bdcf4a 100644 --- a/server/internal/handler/user_context_middleware_test.go +++ b/server/internal/handler/user_context_middleware_test.go @@ -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) + } +} diff --git a/server/internal/infrastructure/postgres/schema.sql b/server/internal/infrastructure/postgres/schema.sql index ff87ae8..9ebf2c2 100644 --- a/server/internal/infrastructure/postgres/schema.sql +++ b/server/internal/infrastructure/postgres/schema.sql @@ -72,6 +72,46 @@ ALTER TABLE projects ADD COLUMN IF NOT EXISTS snapshot_id TEXT NOT NULL DEFAULT ALTER TABLE projects ADD COLUMN IF NOT EXISTS last_thread_id TEXT NOT NULL DEFAULT ''; CREATE INDEX IF NOT EXISTS projects_user_updated_idx ON projects(user_id, updated_at DESC); +CREATE TABLE IF NOT EXISTS project_share_policies ( + project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE, + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash BYTEA NOT NULL UNIQUE, + token_ciphertext BYTEA NOT NULL, + link_permission TEXT NOT NULL DEFAULT 'private' CHECK (link_permission IN ('private', 'canvas', 'viewer', 'editor')), + policy_version BIGINT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS project_share_policies_owner_idx ON project_share_policies(owner_id, updated_at DESC); + +CREATE TABLE IF NOT EXISTS project_share_members ( + id UUID PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES project_share_policies(project_id) ON DELETE CASCADE, + identifier_type TEXT NOT NULL CHECK (identifier_type IN ('email', 'phone')), + identifier_hash BYTEA NOT NULL, + identifier_ciphertext BYTEA NOT NULL, + permission TEXT NOT NULL CHECK (permission IN ('canvas', 'viewer', 'editor')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(project_id, identifier_type, identifier_hash) +); + +CREATE INDEX IF NOT EXISTS project_share_members_project_idx ON project_share_members(project_id, created_at ASC); + +CREATE TABLE IF NOT EXISTS project_share_visits ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES project_share_policies(project_id) ON DELETE CASCADE, + visitor_hash BYTEA NOT NULL, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + visit_count BIGINT NOT NULL DEFAULT 1, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(project_id, visitor_hash) +); + +CREATE INDEX IF NOT EXISTS project_share_visits_project_seen_idx ON project_share_visits(project_id, last_seen_at DESC); + CREATE TABLE IF NOT EXISTS canvas_nodes ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, diff --git a/server/internal/logic/delete_share_member_logic.go b/server/internal/logic/delete_share_member_logic.go new file mode 100644 index 0000000..874f65e --- /dev/null +++ b/server/internal/logic/delete_share_member_logic.go @@ -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 +} diff --git a/server/internal/logic/get_project_logic.go b/server/internal/logic/get_project_logic.go index 7f9b974..fd33c19 100644 --- a/server/internal/logic/get_project_logic.go +++ b/server/internal/logic/get_project_logic.go @@ -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 } diff --git a/server/internal/logic/get_share_settings_logic.go b/server/internal/logic/get_share_settings_logic.go new file mode 100644 index 0000000..8578143 --- /dev/null +++ b/server/internal/logic/get_share_settings_logic.go @@ -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 +} diff --git a/server/internal/logic/invite_share_member_logic.go b/server/internal/logic/invite_share_member_logic.go new file mode 100644 index 0000000..69237f7 --- /dev/null +++ b/server/internal/logic/invite_share_member_logic.go @@ -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 +} diff --git a/server/internal/logic/list_share_visitors_logic.go b/server/internal/logic/list_share_visitors_logic.go new file mode 100644 index 0000000..2659a02 --- /dev/null +++ b/server/internal/logic/list_share_visitors_logic.go @@ -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 +} diff --git a/server/internal/logic/query_project_logic.go b/server/internal/logic/query_project_logic.go index f28726b..1771fad 100644 --- a/server/internal/logic/query_project_logic.go +++ b/server/internal/logic/query_project_logic.go @@ -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, }, } } diff --git a/server/internal/logic/resolve_share_logic.go b/server/internal/logic/resolve_share_logic.go new file mode 100644 index 0000000..4e7f0e4 --- /dev/null +++ b/server/internal/logic/resolve_share_logic.go @@ -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 +} diff --git a/server/internal/logic/revoke_share_access_logic.go b/server/internal/logic/revoke_share_access_logic.go new file mode 100644 index 0000000..517a9f5 --- /dev/null +++ b/server/internal/logic/revoke_share_access_logic.go @@ -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 +} diff --git a/server/internal/logic/sharing_mapper.go b/server/internal/logic/sharing_mapper.go new file mode 100644 index 0000000..8578bc1 --- /dev/null +++ b/server/internal/logic/sharing_mapper.go @@ -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 +} diff --git a/server/internal/logic/sharing_mapper_test.go b/server/internal/logic/sharing_mapper_test.go new file mode 100644 index 0000000..4e03a64 --- /dev/null +++ b/server/internal/logic/sharing_mapper_test.go @@ -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) + } +} diff --git a/server/internal/logic/submit_generator_task_logic.go b/server/internal/logic/submit_generator_task_logic.go index b7196e9..0d9dfb1 100644 --- a/server/internal/logic/submit_generator_task_logic.go +++ b/server/internal/logic/submit_generator_task_logic.go @@ -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, diff --git a/server/internal/logic/update_share_link_logic.go b/server/internal/logic/update_share_link_logic.go new file mode 100644 index 0000000..728f610 --- /dev/null +++ b/server/internal/logic/update_share_link_logic.go @@ -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 +} diff --git a/server/internal/logic/update_share_member_logic.go b/server/internal/logic/update_share_member_logic.go new file mode 100644 index 0000000..5dc524a --- /dev/null +++ b/server/internal/logic/update_share_member_logic.go @@ -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 +} diff --git a/server/internal/modules/sharing/context.go b/server/internal/modules/sharing/context.go new file mode 100644 index 0000000..8003ce0 --- /dev/null +++ b/server/internal/modules/sharing/context.go @@ -0,0 +1,30 @@ +package sharing + +import "context" + +type actorContextKey struct{} +type accessContextKey struct{} + +func ContextWithActor(ctx context.Context, actor Actor) context.Context { + return context.WithValue(ctx, actorContextKey{}, actor) +} + +func ActorFromContext(ctx context.Context) Actor { + if ctx == nil { + return Actor{} + } + actor, _ := ctx.Value(actorContextKey{}).(Actor) + return actor +} + +func ContextWithAccess(ctx context.Context, access Access) context.Context { + return context.WithValue(ctx, accessContextKey{}, access) +} + +func AccessFromContext(ctx context.Context) (Access, bool) { + if ctx == nil { + return Access{}, false + } + access, ok := ctx.Value(accessContextKey{}).(Access) + return access, ok +} diff --git a/server/internal/modules/sharing/crypto.go b/server/internal/modules/sharing/crypto.go new file mode 100644 index 0000000..f04bdc4 --- /dev/null +++ b/server/internal/modules/sharing/crypto.go @@ -0,0 +1,163 @@ +package sharing + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "fmt" + "io" + "net/mail" + "regexp" + "strings" +) + +const shareIDLength = 27 + +var ( + base62Alphabet = []byte("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz") + shareIDPattern = regexp.MustCompile(`^[0-9A-Za-z]{27}$`) + phonePattern = regexp.MustCompile(`^\+?[0-9]{6,20}$`) +) + +type secureCodec struct { + aead cipher.AEAD + rand io.Reader +} + +func newSecureCodec(secret string) (*secureCodec, error) { + secret = strings.TrimSpace(secret) + if secret == "" { + return nil, fmt.Errorf("%w: sharing encryption secret is required", ErrInvalidInput) + } + key := sha256.Sum256([]byte(secret)) + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, err + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return &secureCodec{aead: aead, rand: rand.Reader}, nil +} + +func (c *secureCodec) newShareID() (string, error) { + result := make([]byte, 0, shareIDLength) + buffer := make([]byte, 64) + for len(result) < shareIDLength { + if _, err := io.ReadFull(c.rand, buffer); err != nil { + return "", err + } + for _, value := range buffer { + if value >= 248 { + continue + } + result = append(result, base62Alphabet[int(value)%len(base62Alphabet)]) + if len(result) == shareIDLength { + break + } + } + } + return string(result), nil +} + +func (c *secureCodec) encrypt(value string) ([]byte, error) { + nonce := make([]byte, c.aead.NonceSize()) + if _, err := io.ReadFull(c.rand, nonce); err != nil { + return nil, err + } + return c.aead.Seal(nonce, nonce, []byte(value), nil), nil +} + +func (c *secureCodec) decrypt(value []byte) (string, error) { + if len(value) < c.aead.NonceSize() { + return "", fmt.Errorf("%w: encrypted share value is invalid", ErrInvalidInput) + } + nonce := value[:c.aead.NonceSize()] + plaintext, err := c.aead.Open(nil, nonce, value[c.aead.NonceSize():], nil) + if err != nil { + return "", err + } + return string(plaintext), nil +} + +func hashValue(value string) []byte { + digest := sha256.Sum256([]byte(value)) + return digest[:] +} + +func hashID(value []byte) string { + if len(value) > 16 { + value = value[:16] + } + return hex.EncodeToString(value) +} + +func hashesEqual(left []byte, right []byte) bool { + return len(left) == len(right) && subtle.ConstantTimeCompare(left, right) == 1 +} + +func validShareID(value string) bool { + return shareIDPattern.MatchString(value) +} + +func normalizeIdentifier(value string) (string, string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", "", fmt.Errorf("%w: email or phone is required", ErrInvalidInput) + } + if strings.Contains(value, "@") { + address, err := mail.ParseAddress(value) + if err != nil || !strings.EqualFold(strings.TrimSpace(address.Address), value) { + return "", "", fmt.Errorf("%w: email is invalid", ErrInvalidInput) + } + return "email", strings.ToLower(address.Address), nil + } + phone := strings.NewReplacer(" ", "", "-", "", "(", "", ")", "").Replace(value) + if !phonePattern.MatchString(phone) { + return "", "", fmt.Errorf("%w: phone is invalid", ErrInvalidInput) + } + return "phone", phone, nil +} + +func actorIdentifiers(actor Actor) []string { + seen := make(map[string]struct{}) + appendIdentifier := func(values *[]string, value string) { + kind, normalized, err := normalizeIdentifier(value) + if err != nil || kind == "" { + return + } + key := kind + ":" + normalized + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + *values = append(*values, key) + } + values := make([]string, 0, 3) + appendIdentifier(&values, actor.Email) + appendIdentifier(&values, actor.Phone) + if strings.TrimSpace(actor.CountryCode) != "" && strings.TrimSpace(actor.Phone) != "" { + countryCode := strings.TrimPrefix(strings.TrimSpace(actor.CountryCode), "+") + phone := strings.TrimPrefix(strings.TrimSpace(actor.Phone), "+") + appendIdentifier(&values, "+"+countryCode+phone) + } + return values +} + +func ActorOwnsIdentifier(actor Actor, identifier string) bool { + identifierType, normalized, err := normalizeIdentifier(identifier) + if err != nil { + return false + } + candidateHash := hashValue(identifierType + ":" + normalized) + for _, actorIdentifier := range actorIdentifiers(actor) { + if hashesEqual(candidateHash, hashValue(actorIdentifier)) { + return true + } + } + return false +} diff --git a/server/internal/modules/sharing/memory_store.go b/server/internal/modules/sharing/memory_store.go new file mode 100644 index 0000000..43a0efe --- /dev/null +++ b/server/internal/modules/sharing/memory_store.go @@ -0,0 +1,246 @@ +package sharing + +import ( + "context" + "sort" + "sync" + "time" +) + +type MemoryStore struct { + mu sync.RWMutex + policies map[string]PolicyRecord + tokens map[string]string + members map[string]map[string]MemberRecord + visits map[string]map[string]VisitRecord +} + +func NewMemoryStore() *MemoryStore { + return &MemoryStore{ + policies: make(map[string]PolicyRecord), + tokens: make(map[string]string), + members: make(map[string]map[string]MemberRecord), + visits: make(map[string]map[string]VisitRecord), + } +} + +func (s *MemoryStore) GetPolicyByProject(ctx context.Context, projectID string) (PolicyRecord, error) { + if err := ctx.Err(); err != nil { + return PolicyRecord{}, err + } + s.mu.RLock() + defer s.mu.RUnlock() + policy, ok := s.policies[projectID] + if !ok { + return PolicyRecord{}, ErrNotFound + } + return clonePolicy(policy), nil +} + +func (s *MemoryStore) GetPolicyByTokenHash(ctx context.Context, tokenHash []byte) (PolicyRecord, error) { + if err := ctx.Err(); err != nil { + return PolicyRecord{}, err + } + s.mu.RLock() + defer s.mu.RUnlock() + projectID, ok := s.tokens[string(tokenHash)] + if !ok { + return PolicyRecord{}, ErrNotFound + } + policy, ok := s.policies[projectID] + if !ok { + return PolicyRecord{}, ErrNotFound + } + return clonePolicy(policy), nil +} + +func (s *MemoryStore) CreatePolicy(ctx context.Context, policy PolicyRecord) (PolicyRecord, error) { + if err := ctx.Err(); err != nil { + return PolicyRecord{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + if existing, ok := s.policies[policy.ProjectID]; ok { + return clonePolicy(existing), nil + } + policy = clonePolicy(policy) + s.policies[policy.ProjectID] = policy + s.tokens[string(policy.TokenHash)] = policy.ProjectID + return clonePolicy(policy), nil +} + +func (s *MemoryStore) UpdatePolicyPermission(ctx context.Context, projectID string, ownerID string, permission Permission, updatedAt time.Time) (PolicyRecord, error) { + if err := ctx.Err(); err != nil { + return PolicyRecord{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + policy, ok := s.policies[projectID] + if !ok { + return PolicyRecord{}, ErrNotFound + } + if policy.OwnerID != ownerID { + return PolicyRecord{}, ErrForbidden + } + policy.LinkPermission = permission + policy.Version++ + policy.UpdatedAt = updatedAt + s.policies[projectID] = policy + return clonePolicy(policy), nil +} + +func (s *MemoryStore) DeleteAll(ctx context.Context, projectID string, ownerID string) error { + if err := ctx.Err(); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + policy, ok := s.policies[projectID] + if !ok { + return nil + } + if policy.OwnerID != ownerID { + return ErrForbidden + } + delete(s.tokens, string(policy.TokenHash)) + delete(s.policies, projectID) + delete(s.members, projectID) + delete(s.visits, projectID) + return nil +} + +func (s *MemoryStore) ListMembers(ctx context.Context, projectID string) ([]MemberRecord, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + s.mu.RLock() + defer s.mu.RUnlock() + items := make([]MemberRecord, 0, len(s.members[projectID])) + for _, member := range s.members[projectID] { + items = append(items, cloneMember(member)) + } + sort.Slice(items, func(i, j int) bool { return items[i].CreatedAt.Before(items[j].CreatedAt) }) + return items, nil +} + +func (s *MemoryStore) CreateMember(ctx context.Context, member MemberRecord) (MemberRecord, error) { + if err := ctx.Err(); err != nil { + return MemberRecord{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.policies[member.ProjectID]; !ok { + return MemberRecord{}, ErrNotFound + } + if s.members[member.ProjectID] == nil { + s.members[member.ProjectID] = make(map[string]MemberRecord) + } + for _, existing := range s.members[member.ProjectID] { + if existing.IdentifierType == member.IdentifierType && hashesEqual(existing.IdentifierHash, member.IdentifierHash) { + return MemberRecord{}, ErrMemberExists + } + } + member = cloneMember(member) + s.members[member.ProjectID][member.ID] = member + return cloneMember(member), nil +} + +func (s *MemoryStore) UpdateMember(ctx context.Context, projectID string, memberID string, permission Permission, updatedAt time.Time) (MemberRecord, error) { + if err := ctx.Err(); err != nil { + return MemberRecord{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + member, ok := s.members[projectID][memberID] + if !ok { + return MemberRecord{}, ErrNotFound + } + member.Permission = permission + member.UpdatedAt = updatedAt + s.members[projectID][memberID] = member + return cloneMember(member), nil +} + +func (s *MemoryStore) DeleteMember(ctx context.Context, projectID string, memberID string) error { + if err := ctx.Err(); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.members[projectID][memberID]; !ok { + return ErrNotFound + } + delete(s.members[projectID], memberID) + return nil +} + +func (s *MemoryStore) CountVisitors(ctx context.Context, projectID string) (int64, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + s.mu.RLock() + defer s.mu.RUnlock() + return int64(len(s.visits[projectID])), nil +} + +func (s *MemoryStore) RecordVisit(ctx context.Context, visit VisitRecord) error { + if err := ctx.Err(); err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.policies[visit.ProjectID]; !ok { + return ErrNotFound + } + if s.visits[visit.ProjectID] == nil { + s.visits[visit.ProjectID] = make(map[string]VisitRecord) + } + key := string(visit.VisitorHash) + if current, ok := s.visits[visit.ProjectID][key]; ok { + current.VisitCount++ + current.LastSeenAt = visit.LastSeenAt + if visit.UserID != "" { + current.UserID = visit.UserID + } + s.visits[visit.ProjectID][key] = current + return nil + } + s.visits[visit.ProjectID][key] = cloneVisit(visit) + return nil +} + +func (s *MemoryStore) ListVisitors(ctx context.Context, projectID string, limit int64) ([]VisitRecord, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + s.mu.RLock() + defer s.mu.RUnlock() + items := make([]VisitRecord, 0, len(s.visits[projectID])) + for _, visit := range s.visits[projectID] { + items = append(items, cloneVisit(visit)) + } + sort.Slice(items, func(i, j int) bool { return items[i].LastSeenAt.After(items[j].LastSeenAt) }) + if limit > 0 && int64(len(items)) > limit { + items = items[:limit] + } + return items, nil +} + +func (s *MemoryStore) Close() error { return nil } + +func clonePolicy(policy PolicyRecord) PolicyRecord { + policy.TokenHash = append([]byte(nil), policy.TokenHash...) + policy.TokenCiphertext = append([]byte(nil), policy.TokenCiphertext...) + return policy +} + +func cloneMember(member MemberRecord) MemberRecord { + member.IdentifierHash = append([]byte(nil), member.IdentifierHash...) + member.IdentifierCiphertext = append([]byte(nil), member.IdentifierCiphertext...) + return member +} + +func cloneVisit(visit VisitRecord) VisitRecord { + visit.VisitorHash = append([]byte(nil), visit.VisitorHash...) + return visit +} diff --git a/server/internal/modules/sharing/postgres_store.go b/server/internal/modules/sharing/postgres_store.go new file mode 100644 index 0000000..8543f45 --- /dev/null +++ b/server/internal/modules/sharing/postgres_store.go @@ -0,0 +1,247 @@ +package sharing + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +type PostgresStore struct { + pool *pgxpool.Pool +} + +func NewPostgresStore(ctx context.Context, dataSource string) (*PostgresStore, error) { + pool, err := pgxpool.New(ctx, dataSource) + if err != nil { + return nil, err + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, err + } + return &PostgresStore{pool: pool}, nil +} + +func (s *PostgresStore) GetPolicyByProject(ctx context.Context, projectID string) (PolicyRecord, error) { + return scanPolicy(s.pool.QueryRow(ctx, ` +SELECT project_id, owner_id, token_hash, token_ciphertext, link_permission, policy_version, created_at, updated_at +FROM project_share_policies +WHERE project_id = $1 +`, projectID)) +} + +func (s *PostgresStore) GetPolicyByTokenHash(ctx context.Context, tokenHash []byte) (PolicyRecord, error) { + return scanPolicy(s.pool.QueryRow(ctx, ` +SELECT project_id, owner_id, token_hash, token_ciphertext, link_permission, policy_version, created_at, updated_at +FROM project_share_policies +WHERE token_hash = $1 +`, tokenHash)) +} + +func (s *PostgresStore) CreatePolicy(ctx context.Context, policy PolicyRecord) (PolicyRecord, error) { + created, err := scanPolicy(s.pool.QueryRow(ctx, ` +INSERT INTO project_share_policies ( + project_id, owner_id, token_hash, token_ciphertext, link_permission, policy_version, created_at, updated_at +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +ON CONFLICT (project_id) DO NOTHING +RETURNING project_id, owner_id, token_hash, token_ciphertext, link_permission, policy_version, created_at, updated_at +`, policy.ProjectID, toPGUUID(policy.OwnerID), policy.TokenHash, policy.TokenCiphertext, string(policy.LinkPermission), policy.Version, policy.CreatedAt, policy.UpdatedAt)) + if err == nil { + return created, nil + } + if errors.Is(err, ErrNotFound) { + return s.GetPolicyByProject(ctx, policy.ProjectID) + } + return PolicyRecord{}, err +} + +func (s *PostgresStore) UpdatePolicyPermission(ctx context.Context, projectID string, ownerID string, permission Permission, updatedAt time.Time) (PolicyRecord, error) { + return scanPolicy(s.pool.QueryRow(ctx, ` +UPDATE project_share_policies +SET link_permission = $3, policy_version = policy_version + 1, updated_at = $4 +WHERE project_id = $1 AND owner_id = $2 +RETURNING project_id, owner_id, token_hash, token_ciphertext, link_permission, policy_version, created_at, updated_at +`, projectID, toPGUUID(ownerID), string(permission), updatedAt)) +} + +func (s *PostgresStore) DeleteAll(ctx context.Context, projectID string, ownerID string) error { + result, err := s.pool.Exec(ctx, `DELETE FROM project_share_policies WHERE project_id = $1 AND owner_id = $2`, projectID, toPGUUID(ownerID)) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + if _, getErr := s.GetPolicyByProject(ctx, projectID); getErr == nil { + return ErrForbidden + } else if !errors.Is(getErr, ErrNotFound) { + return getErr + } + } + return nil +} + +func (s *PostgresStore) ListMembers(ctx context.Context, projectID string) ([]MemberRecord, error) { + rows, err := s.pool.Query(ctx, ` +SELECT id, project_id, identifier_type, identifier_hash, identifier_ciphertext, permission, created_at, updated_at +FROM project_share_members +WHERE project_id = $1 +ORDER BY created_at ASC +`, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + items := make([]MemberRecord, 0) + for rows.Next() { + member, scanErr := scanMember(rows) + if scanErr != nil { + return nil, scanErr + } + items = append(items, member) + } + return items, rows.Err() +} + +func (s *PostgresStore) CreateMember(ctx context.Context, member MemberRecord) (MemberRecord, error) { + created, err := scanMember(s.pool.QueryRow(ctx, ` +INSERT INTO project_share_members ( + id, project_id, identifier_type, identifier_hash, identifier_ciphertext, permission, created_at, updated_at +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, project_id, identifier_type, identifier_hash, identifier_ciphertext, permission, created_at, updated_at +`, toPGUUID(member.ID), member.ProjectID, member.IdentifierType, member.IdentifierHash, member.IdentifierCiphertext, string(member.Permission), member.CreatedAt, member.UpdatedAt)) + if err != nil { + var postgresErr *pgconn.PgError + if errors.As(err, &postgresErr) && postgresErr.Code == "23505" { + return MemberRecord{}, ErrMemberExists + } + return MemberRecord{}, err + } + return created, nil +} + +func (s *PostgresStore) UpdateMember(ctx context.Context, projectID string, memberID string, permission Permission, updatedAt time.Time) (MemberRecord, error) { + return scanMember(s.pool.QueryRow(ctx, ` +UPDATE project_share_members +SET permission = $3, updated_at = $4 +WHERE project_id = $1 AND id = $2 +RETURNING id, project_id, identifier_type, identifier_hash, identifier_ciphertext, permission, created_at, updated_at +`, projectID, toPGUUID(memberID), string(permission), updatedAt)) +} + +func (s *PostgresStore) DeleteMember(ctx context.Context, projectID string, memberID string) error { + result, err := s.pool.Exec(ctx, `DELETE FROM project_share_members WHERE project_id = $1 AND id = $2`, projectID, toPGUUID(memberID)) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (s *PostgresStore) CountVisitors(ctx context.Context, projectID string) (int64, error) { + var count int64 + err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM project_share_visits WHERE project_id = $1`, projectID).Scan(&count) + return count, err +} + +func (s *PostgresStore) RecordVisit(ctx context.Context, visit VisitRecord) error { + var userID any + if visit.UserID != "" { + userID = toPGUUID(visit.UserID) + } + _, err := s.pool.Exec(ctx, ` +INSERT INTO project_share_visits ( + id, project_id, visitor_hash, user_id, visit_count, first_seen_at, last_seen_at +) +VALUES ($1, $2, $3, $4, 1, $5, $6) +ON CONFLICT (project_id, visitor_hash) DO UPDATE SET + user_id = COALESCE(EXCLUDED.user_id, project_share_visits.user_id), + visit_count = project_share_visits.visit_count + 1, + last_seen_at = EXCLUDED.last_seen_at +`, visit.ID, visit.ProjectID, visit.VisitorHash, userID, visit.FirstSeenAt, visit.LastSeenAt) + return err +} + +func (s *PostgresStore) ListVisitors(ctx context.Context, projectID string, limit int64) ([]VisitRecord, error) { + rows, err := s.pool.Query(ctx, ` +SELECT id, project_id, visitor_hash, user_id, visit_count, first_seen_at, last_seen_at +FROM project_share_visits +WHERE project_id = $1 +ORDER BY last_seen_at DESC +LIMIT $2 +`, projectID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := make([]VisitRecord, 0) + for rows.Next() { + var visit VisitRecord + var userID pgtype.UUID + if err := rows.Scan(&visit.ID, &visit.ProjectID, &visit.VisitorHash, &userID, &visit.VisitCount, &visit.FirstSeenAt, &visit.LastSeenAt); err != nil { + return nil, err + } + if userID.Valid { + visit.UserID = uuid.UUID(userID.Bytes).String() + } + items = append(items, visit) + } + return items, rows.Err() +} + +func (s *PostgresStore) Close() error { + if s != nil && s.pool != nil { + s.pool.Close() + } + return nil +} + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanPolicy(row rowScanner) (PolicyRecord, error) { + var policy PolicyRecord + var ownerID pgtype.UUID + if err := row.Scan(&policy.ProjectID, &ownerID, &policy.TokenHash, &policy.TokenCiphertext, &policy.LinkPermission, &policy.Version, &policy.CreatedAt, &policy.UpdatedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return PolicyRecord{}, ErrNotFound + } + return PolicyRecord{}, err + } + if ownerID.Valid { + policy.OwnerID = uuid.UUID(ownerID.Bytes).String() + } + return policy, nil +} + +func scanMember(row rowScanner) (MemberRecord, error) { + var member MemberRecord + var id pgtype.UUID + if err := row.Scan(&id, &member.ProjectID, &member.IdentifierType, &member.IdentifierHash, &member.IdentifierCiphertext, &member.Permission, &member.CreatedAt, &member.UpdatedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return MemberRecord{}, ErrNotFound + } + return MemberRecord{}, err + } + if id.Valid { + member.ID = uuid.UUID(id.Bytes).String() + } + return member, nil +} + +func toPGUUID(value string) pgtype.UUID { + parsed, err := uuid.Parse(value) + if err != nil { + return pgtype.UUID{} + } + return pgtype.UUID{Bytes: parsed, Valid: true} +} diff --git a/server/internal/modules/sharing/service.go b/server/internal/modules/sharing/service.go new file mode 100644 index 0000000..8d21be7 --- /dev/null +++ b/server/internal/modules/sharing/service.go @@ -0,0 +1,354 @@ +package sharing + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" +) + +const defaultVisitorLimit int64 = 100 + +type Service struct { + store Store + codec *secureCodec + now func() time.Time +} + +func NewService(store Store, encryptionSecret string) (*Service, error) { + if store == nil { + return nil, fmt.Errorf("%w: sharing store is required", ErrInvalidInput) + } + codec, err := newSecureCodec(encryptionSecret) + if err != nil { + return nil, err + } + return &Service{store: store, codec: codec, now: time.Now}, nil +} + +func (s *Service) Close() error { + if s == nil || s.store == nil { + return nil + } + return s.store.Close() +} + +func ParseLinkPermission(value string) (Permission, error) { + permission := Permission(strings.ToLower(strings.TrimSpace(value))) + switch permission { + case PermissionPrivate, PermissionCanvas, PermissionViewer, PermissionEditor: + return permission, nil + default: + return "", fmt.Errorf("%w: unsupported link permission", ErrInvalidInput) + } +} + +func ParseMemberPermission(value string) (Permission, error) { + permission := Permission(strings.ToLower(strings.TrimSpace(value))) + switch permission { + case PermissionCanvas, PermissionViewer, PermissionEditor: + return permission, nil + default: + return "", fmt.Errorf("%w: unsupported member permission", ErrInvalidInput) + } +} + +func (s *Service) Settings(ctx context.Context, projectID string, ownerID string) (Settings, error) { + projectID = strings.TrimSpace(projectID) + ownerID = strings.TrimSpace(ownerID) + policy, err := s.store.GetPolicyByProject(ctx, projectID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return Settings{ProjectID: projectID, OwnerID: ownerID, LinkPermission: PermissionPrivate, Members: []Member{}}, nil + } + return Settings{}, err + } + if policy.OwnerID != ownerID { + return Settings{}, ErrForbidden + } + return s.settingsFromPolicy(ctx, policy) +} + +func (s *Service) UpdateLink(ctx context.Context, projectID string, ownerID string, permission Permission) (Settings, error) { + if permission == PermissionPrivate { + policy, err := s.store.GetPolicyByProject(ctx, projectID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return Settings{ProjectID: projectID, OwnerID: ownerID, LinkPermission: PermissionPrivate, Members: []Member{}}, nil + } + return Settings{}, err + } + if policy.OwnerID != ownerID { + return Settings{}, ErrForbidden + } + policy, err = s.store.UpdatePolicyPermission(ctx, projectID, ownerID, permission, s.now().UTC()) + if err != nil { + return Settings{}, err + } + return s.settingsFromPolicy(ctx, policy) + } + policy, err := s.ensurePolicy(ctx, projectID, ownerID) + if err != nil { + return Settings{}, err + } + policy, err = s.store.UpdatePolicyPermission(ctx, projectID, ownerID, permission, s.now().UTC()) + if err != nil { + return Settings{}, err + } + return s.settingsFromPolicy(ctx, policy) +} + +func (s *Service) Invite(ctx context.Context, projectID string, owner Actor, identifier string, permission Permission) (Settings, error) { + identifierType, normalized, err := normalizeIdentifier(identifier) + if err != nil { + return Settings{}, err + } + owner.UserID = strings.TrimSpace(owner.UserID) + if !owner.Authenticated || owner.UserID == "" { + return Settings{}, ErrForbidden + } + if ActorOwnsIdentifier(owner, normalized) { + return Settings{}, ErrSelfInvite + } + policy, err := s.ensurePolicy(ctx, projectID, owner.UserID) + if err != nil { + return Settings{}, err + } + ciphertext, err := s.codec.encrypt(normalized) + if err != nil { + return Settings{}, err + } + now := s.now().UTC() + _, err = s.store.CreateMember(ctx, MemberRecord{ + ID: uuid.NewString(), + ProjectID: projectID, + IdentifierType: identifierType, + IdentifierHash: hashValue(identifierType + ":" + normalized), + IdentifierCiphertext: ciphertext, + Permission: permission, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + return Settings{}, err + } + return s.settingsFromPolicy(ctx, policy) +} + +func (s *Service) UpdateMember(ctx context.Context, projectID string, ownerID string, memberID string, permission Permission) (Settings, error) { + policy, err := s.ownerPolicy(ctx, projectID, ownerID) + if err != nil { + return Settings{}, err + } + if _, err := s.store.UpdateMember(ctx, projectID, strings.TrimSpace(memberID), permission, s.now().UTC()); err != nil { + return Settings{}, err + } + return s.settingsFromPolicy(ctx, policy) +} + +func (s *Service) DeleteMember(ctx context.Context, projectID string, ownerID string, memberID string) (Settings, error) { + policy, err := s.ownerPolicy(ctx, projectID, ownerID) + if err != nil { + return Settings{}, err + } + if err := s.store.DeleteMember(ctx, projectID, strings.TrimSpace(memberID)); err != nil { + return Settings{}, err + } + return s.settingsFromPolicy(ctx, policy) +} + +func (s *Service) RevokeAll(ctx context.Context, projectID string, ownerID string) (Settings, error) { + if policy, err := s.store.GetPolicyByProject(ctx, projectID); err == nil && policy.OwnerID != ownerID { + return Settings{}, ErrForbidden + } else if err != nil && !errors.Is(err, ErrNotFound) { + return Settings{}, err + } + if err := s.store.DeleteAll(ctx, projectID, ownerID); err != nil { + return Settings{}, err + } + return Settings{ProjectID: projectID, OwnerID: ownerID, LinkPermission: PermissionPrivate, Members: []Member{}}, nil +} + +func (s *Service) ResolveAccess(ctx context.Context, shareID string, actor Actor) (Access, error) { + shareID = strings.TrimSpace(shareID) + if !validShareID(shareID) { + return Access{}, ErrNotFound + } + policy, err := s.store.GetPolicyByTokenHash(ctx, hashValue(shareID)) + if err != nil { + return Access{}, err + } + permission := policy.LinkPermission + source := "link" + isOwner := actor.Authenticated && actor.UserID != "" && actor.UserID == policy.OwnerID + if isOwner { + permission = PermissionOwner + source = "owner" + } else if actor.Authenticated { + members, listErr := s.store.ListMembers(ctx, policy.ProjectID) + if listErr != nil { + return Access{}, listErr + } + if memberPermission, ok := permissionForActor(members, actor); ok { + permission = memberPermission + source = "member" + } + } + if permission == PermissionPrivate { + return Access{}, ErrNotFound + } + if permission == PermissionEditor && !actor.Authenticated { + return Access{}, ErrLoginRequired + } + capabilities := permission.Capabilities() + if !capabilities.CanViewCanvas { + return Access{}, ErrForbidden + } + return Access{ + ShareID: shareID, + ProjectID: policy.ProjectID, + OwnerID: policy.OwnerID, + Permission: permission, + Source: source, + Authenticated: actor.Authenticated, + IsOwner: isOwner, + Capabilities: capabilities, + PolicyVersion: policy.Version, + }, nil +} + +func (s *Service) RecordVisit(ctx context.Context, access Access, actor Actor, visitorID string) error { + if access.IsOwner { + return nil + } + visitorKey := strings.TrimSpace(visitorID) + userID := "" + if actor.Authenticated && actor.UserID != "" { + visitorKey = "user:" + actor.UserID + userID = actor.UserID + } + if visitorKey == "" || len(visitorKey) > 128 { + return nil + } + visitorHash := hashValue("visit:" + access.ProjectID + ":" + visitorKey) + now := s.now().UTC() + return s.store.RecordVisit(ctx, VisitRecord{ + ID: hashID(visitorHash), + ProjectID: access.ProjectID, + VisitorHash: visitorHash, + UserID: userID, + VisitCount: 1, + FirstSeenAt: now, + LastSeenAt: now, + }) +} + +func (s *Service) Visitors(ctx context.Context, projectID string, ownerID string) ([]VisitRecord, error) { + if _, err := s.ownerPolicy(ctx, projectID, ownerID); err != nil { + if errors.Is(err, ErrNotFound) { + return []VisitRecord{}, nil + } + return nil, err + } + return s.store.ListVisitors(ctx, projectID, defaultVisitorLimit) +} + +func (s *Service) ensurePolicy(ctx context.Context, projectID string, ownerID string) (PolicyRecord, error) { + policy, err := s.store.GetPolicyByProject(ctx, projectID) + if err == nil { + if policy.OwnerID != ownerID { + return PolicyRecord{}, ErrForbidden + } + return policy, nil + } + if !errors.Is(err, ErrNotFound) { + return PolicyRecord{}, err + } + shareID, err := s.codec.newShareID() + if err != nil { + return PolicyRecord{}, err + } + ciphertext, err := s.codec.encrypt(shareID) + if err != nil { + return PolicyRecord{}, err + } + now := s.now().UTC() + return s.store.CreatePolicy(ctx, PolicyRecord{ + ProjectID: projectID, + OwnerID: ownerID, + TokenHash: hashValue(shareID), + TokenCiphertext: ciphertext, + LinkPermission: PermissionPrivate, + Version: 1, + CreatedAt: now, + UpdatedAt: now, + }) +} + +func (s *Service) ownerPolicy(ctx context.Context, projectID string, ownerID string) (PolicyRecord, error) { + policy, err := s.store.GetPolicyByProject(ctx, projectID) + if err != nil { + return PolicyRecord{}, err + } + if policy.OwnerID != ownerID { + return PolicyRecord{}, ErrForbidden + } + return policy, nil +} + +func (s *Service) settingsFromPolicy(ctx context.Context, policy PolicyRecord) (Settings, error) { + shareID, err := s.codec.decrypt(policy.TokenCiphertext) + if err != nil { + return Settings{}, err + } + if !validShareID(shareID) || !hashesEqual(hashValue(shareID), policy.TokenHash) { + return Settings{}, fmt.Errorf("%w: share token integrity check failed", ErrInvalidInput) + } + records, err := s.store.ListMembers(ctx, policy.ProjectID) + if err != nil { + return Settings{}, err + } + members := make([]Member, 0, len(records)) + for _, record := range records { + identifier, decryptErr := s.codec.decrypt(record.IdentifierCiphertext) + if decryptErr != nil { + return Settings{}, decryptErr + } + members = append(members, Member{ + ID: record.ID, + IdentifierType: record.IdentifierType, + Identifier: identifier, + Permission: record.Permission, + CreatedAt: record.CreatedAt, + UpdatedAt: record.UpdatedAt, + }) + } + visitorCount, err := s.store.CountVisitors(ctx, policy.ProjectID) + if err != nil { + return Settings{}, err + } + return Settings{ + ProjectID: policy.ProjectID, + OwnerID: policy.OwnerID, + ShareID: shareID, + LinkPermission: policy.LinkPermission, + Members: members, + VisitorCount: visitorCount, + UpdatedAt: policy.UpdatedAt, + }, nil +} + +func permissionForActor(members []MemberRecord, actor Actor) (Permission, bool) { + identifiers := actorIdentifiers(actor) + for _, member := range members { + for _, identifier := range identifiers { + if hashesEqual(member.IdentifierHash, hashValue(identifier)) { + return member.Permission, true + } + } + } + return "", false +} diff --git a/server/internal/modules/sharing/service_test.go b/server/internal/modules/sharing/service_test.go new file mode 100644 index 0000000..d49b531 --- /dev/null +++ b/server/internal/modules/sharing/service_test.go @@ -0,0 +1,283 @@ +package sharing + +import ( + "context" + "errors" + "strings" + "sync" + "testing" +) + +const ( + testOwnerID = "91cd197b-8255-4172-9981-77391fd38f33" + testMemberID = "ea4dc900-39a1-45e7-a652-2430499f1a5f" +) + +func newTestService(t *testing.T) (*Service, *MemoryStore) { + t.Helper() + store := NewMemoryStore() + service, err := NewService(store, "test-sharing-secret") + if err != nil { + t.Fatal(err) + } + return service, store +} + +func testOwnerActor() Actor { + return Actor{Authenticated: true, UserID: testOwnerID} +} + +func TestResolveAccessUsesMemberBeforeLink(t *testing.T) { + service, _ := newTestService(t) + ctx := context.Background() + settings, err := service.UpdateLink(ctx, "project-1", testOwnerID, PermissionCanvas) + if err != nil { + t.Fatal(err) + } + if len(settings.ShareID) != shareIDLength || !shareIDPattern.MatchString(settings.ShareID) { + t.Fatalf("expected a %d-character case-sensitive base62 id, got %q", shareIDLength, settings.ShareID) + } + if _, err := service.Invite(ctx, "project-1", testOwnerActor(), "Editor@Example.com", PermissionEditor); err != nil { + t.Fatal(err) + } + + linkAccess, err := service.ResolveAccess(ctx, settings.ShareID, Actor{}) + if err != nil { + t.Fatal(err) + } + if linkAccess.Permission != PermissionCanvas || linkAccess.Capabilities.CanViewChat || linkAccess.Capabilities.CanEdit { + t.Fatalf("unexpected anonymous canvas access: %+v", linkAccess) + } + + memberAccess, err := service.ResolveAccess(ctx, settings.ShareID, Actor{ + Authenticated: true, + UserID: testMemberID, + Email: "editor@example.com", + }) + if err != nil { + t.Fatal(err) + } + if memberAccess.Source != "member" || memberAccess.Permission != PermissionEditor || !memberAccess.Capabilities.CanEdit { + t.Fatalf("expected explicit editor access, got %+v", memberAccess) + } +} + +func TestEditorLinkRequiresAuthentication(t *testing.T) { + service, _ := newTestService(t) + settings, err := service.UpdateLink(context.Background(), "project-1", testOwnerID, PermissionEditor) + if err != nil { + t.Fatal(err) + } + if _, err := service.ResolveAccess(context.Background(), settings.ShareID, Actor{}); !errors.Is(err, ErrLoginRequired) { + t.Fatalf("expected login required, got %v", err) + } + access, err := service.ResolveAccess(context.Background(), settings.ShareID, Actor{Authenticated: true, UserID: testMemberID}) + if err != nil { + t.Fatal(err) + } + if !access.Capabilities.CanEdit || access.Source != "link" { + t.Fatalf("expected authenticated link editor, got %+v", access) + } +} + +func TestRevokeAllInvalidatesTokenAndMembers(t *testing.T) { + service, _ := newTestService(t) + settings, err := service.UpdateLink(context.Background(), "project-1", testOwnerID, PermissionViewer) + if err != nil { + t.Fatal(err) + } + if _, err := service.Invite(context.Background(), "project-1", testOwnerActor(), "+8613800138000", PermissionEditor); err != nil { + t.Fatal(err) + } + if _, err := service.RevokeAll(context.Background(), "project-1", testOwnerID); err != nil { + t.Fatal(err) + } + if _, err := service.ResolveAccess(context.Background(), settings.ShareID, Actor{}); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected revoked link to be missing, got %v", err) + } + privateSettings, err := service.Settings(context.Background(), "project-1", testOwnerID) + if err != nil { + t.Fatal(err) + } + if privateSettings.ShareID != "" || privateSettings.LinkPermission != PermissionPrivate || len(privateSettings.Members) != 0 { + t.Fatalf("expected private empty settings, got %+v", privateSettings) + } +} + +func TestShareIDIsCaseSensitive(t *testing.T) { + service, _ := newTestService(t) + settings, err := service.UpdateLink(context.Background(), "project-1", testOwnerID, PermissionViewer) + if err != nil { + t.Fatal(err) + } + mutated := []byte(settings.ShareID) + for index, value := range mutated { + if value >= 'a' && value <= 'z' { + mutated[index] = value - ('a' - 'A') + break + } + if value >= 'A' && value <= 'Z' { + mutated[index] = value + ('a' - 'A') + break + } + } + if string(mutated) == settings.ShareID { + t.Skip("random token contained no letters") + } + if _, err := service.ResolveAccess(context.Background(), string(mutated), Actor{}); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected case-mutated token to fail, got %v", err) + } +} + +func TestSensitiveValuesAreEncryptedAtRest(t *testing.T) { + service, store := newTestService(t) + settings, err := service.UpdateLink(context.Background(), "project-1", testOwnerID, PermissionCanvas) + if err != nil { + t.Fatal(err) + } + if _, err := service.Invite(context.Background(), "project-1", testOwnerActor(), "private@example.com", PermissionViewer); err != nil { + t.Fatal(err) + } + policy := store.policies["project-1"] + if strings.Contains(string(policy.TokenCiphertext), settings.ShareID) { + t.Fatal("share id must not be stored in plaintext") + } + for _, member := range store.members["project-1"] { + if strings.Contains(string(member.IdentifierCiphertext), "private@example.com") { + t.Fatal("member identifier must not be stored in plaintext") + } + } +} + +func TestRecordVisitDeduplicatesVisitor(t *testing.T) { + service, _ := newTestService(t) + settings, err := service.UpdateLink(context.Background(), "project-1", testOwnerID, PermissionViewer) + if err != nil { + t.Fatal(err) + } + access, err := service.ResolveAccess(context.Background(), settings.ShareID, Actor{}) + if err != nil { + t.Fatal(err) + } + if err := service.RecordVisit(context.Background(), access, Actor{}, "visitor-1234567890"); err != nil { + t.Fatal(err) + } + if err := service.RecordVisit(context.Background(), access, Actor{}, "visitor-1234567890"); err != nil { + t.Fatal(err) + } + visitors, err := service.Visitors(context.Background(), "project-1", testOwnerID) + if err != nil { + t.Fatal(err) + } + if len(visitors) != 1 || visitors[0].VisitCount != 2 { + t.Fatalf("expected one repeat visitor, got %+v", visitors) + } +} + +func TestInviteRejectsOwnerIdentifiers(t *testing.T) { + tests := []struct { + name string + identifier string + }{ + {name: "email is case insensitive", identifier: " OWNER@EXAMPLE.COM "}, + {name: "local phone is normalized", identifier: "138-0013-8000"}, + {name: "international phone is normalized", identifier: "+86 138 0013 8000"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service, store := newTestService(t) + owner := Actor{ + Authenticated: true, + UserID: testOwnerID, + Email: "owner@example.com", + CountryCode: "+86", + Phone: "13800138000", + } + + if _, err := service.Invite(context.Background(), "project-1", owner, tt.identifier, PermissionViewer); !errors.Is(err, ErrSelfInvite) { + t.Fatalf("expected self-invite rejection, got %v", err) + } + if len(store.members["project-1"]) != 0 { + t.Fatal("self-invite must not create a member") + } + }) + } +} + +func TestInviteRejectsNormalizedDuplicateWithoutChangingPermission(t *testing.T) { + tests := []struct { + name string + first string + duplicate string + }{ + {name: "email", first: "Member@Example.com", duplicate: " member@example.com "}, + {name: "phone", first: "+86 139-0013-9000", duplicate: "+8613900139000"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service, store := newTestService(t) + ctx := context.Background() + owner := testOwnerActor() + + if _, err := service.Invite(ctx, "project-1", owner, tt.first, PermissionViewer); err != nil { + t.Fatal(err) + } + if _, err := service.Invite(ctx, "project-1", owner, tt.duplicate, PermissionEditor); !errors.Is(err, ErrMemberExists) { + t.Fatalf("expected duplicate member rejection, got %v", err) + } + + members := store.members["project-1"] + if len(members) != 1 { + t.Fatalf("expected one member, got %d", len(members)) + } + for _, member := range members { + if member.Permission != PermissionViewer { + t.Fatalf("duplicate invite changed permission to %q", member.Permission) + } + } + }) + } +} + +func TestInviteCreatesOneMemberUnderConcurrency(t *testing.T) { + service, store := newTestService(t) + owner := testOwnerActor() + const attempts = 12 + + start := make(chan struct{}) + errorsByAttempt := make(chan error, attempts) + var workers sync.WaitGroup + for index := 0; index < attempts; index++ { + workers.Add(1) + go func() { + defer workers.Done() + <-start + _, err := service.Invite(context.Background(), "project-1", owner, "concurrent@example.com", PermissionViewer) + errorsByAttempt <- err + }() + } + close(start) + workers.Wait() + close(errorsByAttempt) + + successes := 0 + duplicates := 0 + for err := range errorsByAttempt { + switch { + case err == nil: + successes++ + case errors.Is(err, ErrMemberExists): + duplicates++ + default: + t.Fatalf("unexpected concurrent invite error: %v", err) + } + } + if successes != 1 || duplicates != attempts-1 { + t.Fatalf("expected one success and %d duplicates, got %d and %d", attempts-1, successes, duplicates) + } + if len(store.members["project-1"]) != 1 { + t.Fatalf("expected exactly one stored member, got %d", len(store.members["project-1"])) + } +} diff --git a/server/internal/modules/sharing/types.go b/server/internal/modules/sharing/types.go new file mode 100644 index 0000000..459abcd --- /dev/null +++ b/server/internal/modules/sharing/types.go @@ -0,0 +1,165 @@ +package sharing + +import ( + "context" + "errors" + "time" +) + +var ( + ErrNotFound = errors.New("share not found") + ErrForbidden = errors.New("share access forbidden") + ErrLoginRequired = errors.New("login required for edit access") + ErrInvalidInput = errors.New("invalid share input") + ErrSelfInvite = errors.New("project owner cannot be invited") + ErrMemberExists = errors.New("share member already exists") +) + +type Permission string + +const ( + PermissionPrivate Permission = "private" + PermissionCanvas Permission = "canvas" + PermissionViewer Permission = "viewer" + PermissionEditor Permission = "editor" + PermissionOwner Permission = "owner" +) + +type Capability string + +const ( + CapabilityViewCanvas Capability = "view_canvas" + CapabilityViewChat Capability = "view_chat" + CapabilityEdit Capability = "edit" + CapabilityManage Capability = "manage" +) + +type Capabilities struct { + CanViewCanvas bool + CanViewChat bool + CanEdit bool + CanManage bool +} + +func (p Permission) Capabilities() Capabilities { + switch p { + case PermissionOwner: + return Capabilities{CanViewCanvas: true, CanViewChat: true, CanEdit: true, CanManage: true} + case PermissionEditor: + return Capabilities{CanViewCanvas: true, CanViewChat: true, CanEdit: true} + case PermissionViewer: + return Capabilities{CanViewCanvas: true, CanViewChat: true} + case PermissionCanvas: + return Capabilities{CanViewCanvas: true} + default: + return Capabilities{} + } +} + +func (c Capabilities) Allows(capability Capability) bool { + switch capability { + case CapabilityManage: + return c.CanManage + case CapabilityEdit: + return c.CanEdit + case CapabilityViewChat: + return c.CanViewChat + case CapabilityViewCanvas: + return c.CanViewCanvas + default: + return false + } +} + +type Actor struct { + Authenticated bool + UserID string + Email string + CountryCode string + Phone string + Name string + AvatarURL string +} + +type Access struct { + ShareID string + ProjectID string + OwnerID string + Permission Permission + Source string + Authenticated bool + IsOwner bool + Capabilities Capabilities + PolicyVersion int64 +} + +func (a Access) Allows(capability Capability) bool { + return a.Capabilities.Allows(capability) +} + +type PolicyRecord struct { + ProjectID string + OwnerID string + TokenHash []byte + TokenCiphertext []byte + LinkPermission Permission + Version int64 + CreatedAt time.Time + UpdatedAt time.Time +} + +type MemberRecord struct { + ID string + ProjectID string + IdentifierType string + IdentifierHash []byte + IdentifierCiphertext []byte + Permission Permission + CreatedAt time.Time + UpdatedAt time.Time +} + +type Member struct { + ID string + IdentifierType string + Identifier string + Permission Permission + CreatedAt time.Time + UpdatedAt time.Time +} + +type VisitRecord struct { + ID string + ProjectID string + VisitorHash []byte + UserID string + VisitCount int64 + FirstSeenAt time.Time + LastSeenAt time.Time +} + +type Settings struct { + ProjectID string + OwnerID string + ShareID string + LinkPermission Permission + Members []Member + VisitorCount int64 + UpdatedAt time.Time +} + +type Store interface { + GetPolicyByProject(ctx context.Context, projectID string) (PolicyRecord, error) + GetPolicyByTokenHash(ctx context.Context, tokenHash []byte) (PolicyRecord, error) + CreatePolicy(ctx context.Context, policy PolicyRecord) (PolicyRecord, error) + UpdatePolicyPermission(ctx context.Context, projectID string, ownerID string, permission Permission, updatedAt time.Time) (PolicyRecord, error) + DeleteAll(ctx context.Context, projectID string, ownerID string) error + ListMembers(ctx context.Context, projectID string) ([]MemberRecord, error) + CreateMember(ctx context.Context, member MemberRecord) (MemberRecord, error) + UpdateMember(ctx context.Context, projectID string, memberID string, permission Permission, updatedAt time.Time) (MemberRecord, error) + DeleteMember(ctx context.Context, projectID string, memberID string) error + CountVisitors(ctx context.Context, projectID string) (int64, error) + RecordVisit(ctx context.Context, visit VisitRecord) error + ListVisitors(ctx context.Context, projectID string, limit int64) ([]VisitRecord, error) + Close() error +} diff --git a/server/internal/svc/service_context.go b/server/internal/svc/service_context.go index ae53b5b..74ba600 100644 --- a/server/internal/svc/service_context.go +++ b/server/internal/svc/service_context.go @@ -25,6 +25,7 @@ import ( authmodule "img_infinite_canvas/internal/modules/auth" jobmodule "img_infinite_canvas/internal/modules/job" realtimemodule "img_infinite_canvas/internal/modules/realtime" + sharingmodule "img_infinite_canvas/internal/modules/sharing" "github.com/zeromicro/go-zero/core/logx" ) @@ -33,6 +34,7 @@ type ServiceContext struct { Config config.Config DesignService *application.DesignService AuthService *authmodule.Service + ShareService *sharingmodule.Service RealtimeBus realtimemodule.Bus jobQueue design.JobQueue jobWorker backgroundWorker @@ -49,6 +51,7 @@ func NewServiceContext(c config.Config) *ServiceContext { repo := newProjectRepository(c) cacheStore := newCacheStore(c) authService := newAuthService(c, cacheStore) + shareService := newShareService(c) repo = withCache(c, repo, cacheStore) realtimeBus := newRealtimeBus(c) repo = withRealtime(repo, realtimeBus) @@ -75,6 +78,7 @@ func NewServiceContext(c config.Config) *ServiceContext { Config: c, DesignService: service, AuthService: authService, + ShareService: shareService, RealtimeBus: realtimeBus, jobQueue: queue, jobWorker: worker, @@ -150,6 +154,46 @@ func (s *ServiceContext) Close() { logx.Errorf("close auth service failed: %v", err) } } + if s.ShareService != nil { + if err := s.ShareService.Close(); err != nil { + logx.Errorf("close sharing service failed: %v", err) + } + } +} + +func newShareService(c config.Config) *sharingmodule.Service { + var store sharingmodule.Store + switch c.Storage.Driver { + case "", "memory": + store = sharingmodule.NewMemoryStore() + case "postgres": + if strings.TrimSpace(c.Storage.DataSource) == "" { + logx.Must(application.ErrMissingDataSource) + } + postgresStore, err := sharingmodule.NewPostgresStore(context.Background(), c.Storage.DataSource) + if err != nil { + logx.Must(err) + } + store = postgresStore + default: + logx.Must(application.ErrUnsupportedStorage) + store = sharingmodule.NewMemoryStore() + } + secret := strings.TrimSpace(c.Sharing.EncryptionSecret) + if envName := strings.TrimSpace(c.Sharing.EncryptionSecretEnv); envName != "" { + if envValue := strings.TrimSpace(os.Getenv(envName)); envValue != "" { + secret = envValue + } + } + if secret == "" { + secret = strings.TrimSpace(c.Auth.TokenSecret) + } + service, err := sharingmodule.NewService(store, secret) + if err != nil { + _ = store.Close() + logx.Must(err) + } + return service } func newAuthService(c config.Config, cacheStore cacheinfra.Store) *authmodule.Service { diff --git a/server/internal/types/types.go b/server/internal/types/types.go index 0d8807b..ccf03b6 100644 --- a/server/internal/types/types.go +++ b/server/internal/types/types.go @@ -357,6 +357,11 @@ type DeleteProjectsResponse struct { Deleted int64 `json:"deleted"` } +type DeleteShareMemberRequest struct { + Id string `path:"id"` + MemberId string `path:"memberId"` +} + type ExtractNodeTextAsyncRequest struct { Id string `path:"id"` NodeId string `path:"nodeId"` @@ -536,6 +541,12 @@ type InspirationItem struct { Accent string `json:"accent"` } +type InviteShareMemberRequest struct { + Id string `path:"id"` + Identifier string `json:"identifier"` + Permission string `json:"permission"` +} + type LovartAgentThreadListData struct { Page int64 `json:"page"` Total int64 `json:"total"` @@ -796,6 +807,18 @@ type RecognizeObjectResponse struct { BBox NormalizedBox `json:"bbox"` } +type ResolveShareRequest struct { + ShareId string `path:"shareId"` + VisitorId string `header:"X-Share-Visitor,optional"` +} + +type ResolveShareResponse struct { + ShareId string `json:"shareId"` + Project Project `json:"project"` + Access ShareAccess `json:"access"` + UpdatedAt string `json:"updatedAt"` +} + type SaveCanvasRequest struct { Id string `path:"id"` Version string `json:"version,optional"` @@ -840,6 +863,63 @@ type SaveProjectResponse struct { Data SaveProjectData `json:"data"` } +type ShareAccess struct { + Permission string `json:"permission"` + Source string `json:"source"` + Authenticated bool `json:"authenticated"` + IsOwner bool `json:"isOwner"` + Capabilities ShareCapabilities `json:"capabilities"` +} + +type ShareCapabilities struct { + CanViewCanvas bool `json:"canViewCanvas"` + CanViewChat bool `json:"canViewChat"` + CanEdit bool `json:"canEdit"` + CanManage bool `json:"canManage"` +} + +type SharePerson struct { + Id string `json:"id"` + UserId string `json:"userId,optional"` + Name string `json:"name,optional"` + Identifier string `json:"identifier,optional"` + AvatarUrl string `json:"avatarUrl,optional"` + Permission string `json:"permission"` + CreatedAt string `json:"createdAt,optional"` +} + +type ShareSettings struct { + ProjectId string `json:"projectId"` + ShareId string `json:"shareId,optional"` + LinkPermission string `json:"linkPermission"` + Owner SharePerson `json:"owner"` + Members []SharePerson `json:"members"` + VisitorCount int64 `json:"visitorCount"` + UpdatedAt string `json:"updatedAt,optional"` +} + +type ShareSettingsRequest struct { + Id string `path:"id"` +} + +type ShareSettingsResponse struct { + Settings ShareSettings `json:"settings"` +} + +type ShareVisitor struct { + Id string `json:"id"` + UserId string `json:"userId,optional"` + Name string `json:"name"` + Identifier string `json:"identifier,optional"` + AvatarUrl string `json:"avatarUrl,optional"` + VisitCount int64 `json:"visitCount"` + LastSeenAt string `json:"lastSeenAt"` +} + +type ShareVisitorsResponse struct { + Visitors []ShareVisitor `json:"visitors"` +} + type StopAgentTaskRequest struct { Id string `path:"id"` ThreadId string `json:"threadId,optional"` @@ -855,6 +935,17 @@ type UpdateProjectTitleRequest struct { Title string `json:"title"` } +type UpdateShareLinkRequest struct { + Id string `path:"id"` + Permission string `json:"permission"` +} + +type UpdateShareMemberRequest struct { + Id string `path:"id"` + MemberId string `path:"memberId"` + Permission string `json:"permission"` +} + type WechatAuthURLResponse struct { AuthUrl string `json:"authUrl"` State string `json:"state"`