feat(server): add project sharing access control

This commit is contained in:
2026-07-10 14:46:18 +08:00
parent 60130b3d9b
commit 9d47cb9bbd
41 changed files with 3069 additions and 17 deletions
+6
View File
@@ -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"`
+13
View File
@@ -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:
+36
View File
@@ -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"])
}
})
}
}
@@ -0,0 +1,31 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package handler
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"img_infinite_canvas/internal/logic"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
)
func deleteShareMemberHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.DeleteShareMemberRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := logic.NewDeleteShareMemberLogic(r.Context(), svcCtx)
resp, err := l.DeleteShareMember(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}
@@ -0,0 +1,31 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package handler
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"img_infinite_canvas/internal/logic"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
)
func getShareSettingsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ShareSettingsRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := logic.NewGetShareSettingsLogic(r.Context(), svcCtx)
resp, err := l.GetShareSettings(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}
@@ -0,0 +1,31 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package handler
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"img_infinite_canvas/internal/logic"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
)
func inviteShareMemberHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.InviteShareMemberRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := logic.NewInviteShareMemberLogic(r.Context(), svcCtx)
resp, err := l.InviteShareMember(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}
@@ -0,0 +1,31 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package handler
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"img_infinite_canvas/internal/logic"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
)
func listShareVisitorsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ShareSettingsRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := logic.NewListShareVisitorsLogic(r.Context(), svcCtx)
resp, err := l.ListShareVisitors(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}
@@ -0,0 +1,31 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package handler
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"img_infinite_canvas/internal/logic"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
)
func resolveShareHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ResolveShareRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := logic.NewResolveShareLogic(r.Context(), svcCtx)
resp, err := l.ResolveShare(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}
@@ -0,0 +1,31 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package handler
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"img_infinite_canvas/internal/logic"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
)
func revokeShareAccessHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ShareSettingsRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := logic.NewRevokeShareAccessLogic(r.Context(), svcCtx)
resp, err := l.RevokeShareAccess(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}
+40
View File
@@ -254,6 +254,41 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/projects/:id/nodes/:nodeId/text-extraction/async",
Handler: extractNodeTextAsyncHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/projects/:id/sharing",
Handler: getShareSettingsHandler(serverCtx),
},
{
Method: http.MethodDelete,
Path: "/projects/:id/sharing/access",
Handler: revokeShareAccessHandler(serverCtx),
},
{
Method: http.MethodPut,
Path: "/projects/:id/sharing/link",
Handler: updateShareLinkHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/projects/:id/sharing/members",
Handler: inviteShareMemberHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/projects/:id/sharing/members/:memberId",
Handler: updateShareMemberHandler(serverCtx),
},
{
Method: http.MethodDelete,
Path: "/projects/:id/sharing/members/:memberId",
Handler: deleteShareMemberHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/projects/:id/sharing/visitors",
Handler: listShareVisitorsHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/projects/:id/title",
@@ -269,6 +304,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/projects/batch-delete",
Handler: deleteProjectsHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/shares/:shareId",
Handler: resolveShareHandler(serverCtx),
},
},
rest.WithPrefix("/api"),
)
@@ -0,0 +1,31 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package handler
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"img_infinite_canvas/internal/logic"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
)
func updateShareLinkHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpdateShareLinkRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := logic.NewUpdateShareLinkLogic(r.Context(), svcCtx)
resp, err := l.UpdateShareLink(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}
@@ -0,0 +1,31 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package handler
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"img_infinite_canvas/internal/logic"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
)
func updateShareMemberHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpdateShareMemberRequest
if err := httpx.Parse(r, &req); err != nil {
httpx.ErrorCtx(r.Context(), w, err)
return
}
l := logic.NewUpdateShareMemberLogic(r.Context(), svcCtx)
resp, err := l.UpdateShareMember(&req)
if err != nil {
httpx.ErrorCtx(r.Context(), w, err)
} else {
httpx.OkJsonCtx(r.Context(), w, resp)
}
}
}
@@ -2,20 +2,35 @@ package handler
import (
"context"
"errors"
"net/http"
"strings"
"img_infinite_canvas/internal/domain/design"
authmodule "img_infinite_canvas/internal/modules/auth"
sharingmodule "img_infinite_canvas/internal/modules/sharing"
)
type bearerAuthenticator interface {
UserIDFromBearer(ctx context.Context, authorization string) (string, bool)
}
func UserContextMiddleware(authenticator bearerAuthenticator) func(http.HandlerFunc) http.HandlerFunc {
type profileAuthenticator interface {
GetProfile(ctx context.Context, userID string) (authmodule.User, error)
}
type shareRequestAuthorizer interface {
ResolveAccess(ctx context.Context, shareID string, actor sharingmodule.Actor) (sharingmodule.Access, error)
}
func UserContextMiddleware(authenticator bearerAuthenticator, shareAuthorizers ...shareRequestAuthorizer) func(http.HandlerFunc) http.HandlerFunc {
var shareAuthorizer shareRequestAuthorizer
if len(shareAuthorizers) > 0 {
shareAuthorizer = shareAuthorizers[0]
}
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-Id")
userID := ""
authenticated := false
if authenticator != nil {
if tokenUserID, ok := userIDFromRequestAuth(r.Context(), r, authenticator); ok {
@@ -23,11 +38,54 @@ func UserContextMiddleware(authenticator bearerAuthenticator) func(http.HandlerF
authenticated = true
}
}
if requiresAuthenticatedUser(r) && !authenticated {
actor := sharingmodule.Actor{Authenticated: authenticated, UserID: userID}
if authenticated && requestUsesShareAccess(r) {
if profiles, ok := authenticator.(profileAuthenticator); ok {
profile, err := profiles.GetProfile(r.Context(), userID)
if err != nil {
writeAuthRequired(w)
return
}
actor.Email = profile.Email
actor.CountryCode = profile.CountryCode
actor.Phone = profile.Phone
actor.Name = profile.Name
actor.AvatarURL = profile.AvatarURL
}
}
ctx := sharingmodule.ContextWithActor(r.Context(), actor)
ctx = design.ContextWithUserID(ctx, userID)
shareAuthorized := false
shareID := strings.TrimSpace(r.Header.Get("X-Share-Id"))
projectID := shareProjectID(r)
if shareID != "" && r.Method == http.MethodDelete && r.URL.Path == "/api/assets" {
writeShareAccessError(w, sharingmodule.ErrForbidden)
return
}
if shareID != "" && projectID != "" && shareAuthorizer != nil {
access, err := shareAuthorizer.ResolveAccess(ctx, shareID, actor)
if err != nil {
writeShareAccessError(w, err)
return
}
if access.ProjectID != projectID {
writeShareAccessError(w, sharingmodule.ErrNotFound)
return
}
if !access.Allows(requiredShareCapability(r)) {
writeShareAccessError(w, sharingmodule.ErrForbidden)
return
}
ctx = sharingmodule.ContextWithAccess(ctx, access)
ctx = design.ContextWithUserID(ctx, access.OwnerID)
shareAuthorized = true
}
if requiresAuthenticatedUser(r) && !authenticated && !shareAuthorized {
writeAuthRequired(w)
return
}
ctx := design.ContextWithUserID(r.Context(), userID)
next(w, r.WithContext(ctx))
}
}
@@ -39,16 +97,20 @@ func userIDFromRequestAuth(ctx context.Context, r *http.Request, authenticator b
func requiresAuthenticatedUser(r *http.Request) bool {
path := r.URL.Path
if strings.HasPrefix(path, "/v1/") {
return true
}
if !strings.HasPrefix(path, "/api/") {
return false
}
if strings.HasPrefix(path, "/api/auth/") || path == "/api/auth/options" || path == "/api/health" || path == "/api/dashboard" {
if strings.HasPrefix(path, "/api/auth/") ||
strings.HasPrefix(path, "/api/shares/") ||
path == "/api/auth/options" ||
path == "/api/health" ||
path == "/api/dashboard" {
return false
}
return strings.HasPrefix(path, "/api/projects") ||
strings.HasPrefix(path, "/api/account") ||
strings.HasPrefix(path, "/api/assets") ||
strings.HasPrefix(path, "/api/generator/")
return true
}
func writeAuthRequired(w http.ResponseWriter) {
@@ -57,10 +119,76 @@ func writeAuthRequired(w http.ResponseWriter) {
_, _ = w.Write([]byte(`{"error":"login required","errorCode":"auth.login_required","code":401}`))
}
func requestUsesShareAccess(r *http.Request) bool {
return strings.TrimSpace(r.Header.Get("X-Share-Id")) != "" || strings.HasPrefix(r.URL.Path, "/api/shares/")
}
func shareProjectID(r *http.Request) string {
const prefix = "/api/projects/"
if strings.HasPrefix(r.URL.Path, prefix) {
remainder := strings.TrimPrefix(r.URL.Path, prefix)
projectID, _, _ := strings.Cut(remainder, "/")
projectID = strings.TrimSpace(projectID)
if projectID == "" || projectID == "async" || projectID == "batch-delete" {
return ""
}
return projectID
}
if r.URL.Path == "/api/generator/tasks" {
if headerProjectID := strings.TrimSpace(r.Header.Get("X-Share-Project-Id")); headerProjectID != "" {
return headerProjectID
}
return strings.TrimSpace(r.URL.Query().Get("project_id"))
}
return ""
}
func requiredShareCapability(r *http.Request) sharingmodule.Capability {
path := r.URL.Path
if path == "/api/generator/tasks" {
return sharingmodule.CapabilityEdit
}
if strings.Contains(path, "/sharing") || (r.Method == http.MethodDelete && shareProjectRoot(path)) {
return sharingmodule.CapabilityManage
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
return sharingmodule.CapabilityEdit
}
if strings.HasSuffix(path, "/agent/ws") {
return sharingmodule.CapabilityEdit
}
if strings.Contains(path, "/agent/") || strings.HasSuffix(path, "/history") || strings.HasSuffix(path, "/events") {
return sharingmodule.CapabilityViewChat
}
return sharingmodule.CapabilityViewCanvas
}
func shareProjectRoot(path string) bool {
remainder := strings.TrimPrefix(path, "/api/projects/")
return remainder != path && !strings.Contains(remainder, "/")
}
func writeShareAccessError(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
switch {
case errors.Is(err, sharingmodule.ErrLoginRequired):
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"login required for edit access","errorCode":"share.login_required","code":401}`))
case errors.Is(err, sharingmodule.ErrForbidden):
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"share access forbidden","errorCode":"share.forbidden","code":403}`))
case errors.Is(err, sharingmodule.ErrNotFound):
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"share not found","errorCode":"share.not_found","code":404}`))
default:
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"share authorization failed","errorCode":"share.internal","code":500}`))
}
}
func LegacyUserContextMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-Id")
ctx := design.ContextWithUserID(r.Context(), userID)
ctx := design.ContextWithUserID(r.Context(), "")
next(w, r.WithContext(ctx))
}
}
@@ -7,6 +7,7 @@ import (
"testing"
"img_infinite_canvas/internal/domain/design"
sharingmodule "img_infinite_canvas/internal/modules/sharing"
)
type fakeBearerAuthenticator struct{}
@@ -18,6 +19,15 @@ func (fakeBearerAuthenticator) UserIDFromBearer(_ context.Context, authorization
return "", false
}
type fakeShareAuthorizer struct {
access sharingmodule.Access
err error
}
func (f fakeShareAuthorizer) ResolveAccess(_ context.Context, _ string, _ sharingmodule.Actor) (sharingmodule.Access, error) {
return f.access, f.err
}
func TestUserContextMiddlewareUsesAuthorizationHeader(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/projects", nil)
req.Header.Set("Authorization", "Bearer valid")
@@ -58,3 +68,133 @@ func TestUserContextMiddlewareIgnoresLegacyTokenHeaderAndCookie(t *testing.T) {
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
}
}
func TestUserContextMiddlewareIgnoresSpoofedUserIDOnPublicRoute(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/dashboard", nil)
req.Header.Set("X-User-Id", "91cd197b-8255-4172-9981-77391fd38f33")
rec := httptest.NewRecorder()
UserContextMiddleware(fakeBearerAuthenticator{})(func(w http.ResponseWriter, r *http.Request) {
if got := design.UserIDFromContext(r.Context()); got != design.DefaultUserID {
t.Fatalf("expected spoofed identity to be ignored, got %s", got)
}
w.WriteHeader(http.StatusNoContent)
})(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected public route to continue, got %d", rec.Code)
}
}
func TestUserContextMiddlewareAuthorizesAnonymousCanvasShare(t *testing.T) {
ownerID := "d1d2fbbb-97bb-4406-8d0b-ec814cc65092"
authorizer := fakeShareAuthorizer{access: sharingmodule.Access{
ProjectID: "project-1",
OwnerID: ownerID,
Permission: sharingmodule.PermissionCanvas,
Capabilities: sharingmodule.PermissionCanvas.Capabilities(),
}}
req := httptest.NewRequest(http.MethodGet, "/api/projects/project-1", nil)
req.Header.Set("X-Share-Id", "XhRewKnS4it7X9kEMFLcdVJentg")
rec := httptest.NewRecorder()
UserContextMiddleware(fakeBearerAuthenticator{}, authorizer)(func(w http.ResponseWriter, r *http.Request) {
if got := design.UserIDFromContext(r.Context()); got != ownerID {
t.Fatalf("expected owner-scoped repository context, got %s", got)
}
if _, ok := sharingmodule.AccessFromContext(r.Context()); !ok {
t.Fatal("expected effective share access in context")
}
w.WriteHeader(http.StatusNoContent)
})(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected shared read to continue, got %d", rec.Code)
}
}
func TestUserContextMiddlewareDeniesCanvasShareChatHistory(t *testing.T) {
authorizer := fakeShareAuthorizer{access: sharingmodule.Access{
ProjectID: "project-1",
OwnerID: "d1d2fbbb-97bb-4406-8d0b-ec814cc65092",
Permission: sharingmodule.PermissionCanvas,
Capabilities: sharingmodule.PermissionCanvas.Capabilities(),
}}
req := httptest.NewRequest(http.MethodGet, "/api/projects/project-1/history", nil)
req.Header.Set("X-Share-Id", "XhRewKnS4it7X9kEMFLcdVJentg")
rec := httptest.NewRecorder()
called := false
UserContextMiddleware(fakeBearerAuthenticator{}, authorizer)(func(http.ResponseWriter, *http.Request) {
called = true
})(rec, req)
if called {
t.Fatal("expected canvas-only chat history to be blocked")
}
if rec.Code != http.StatusForbidden {
t.Fatalf("expected forbidden, got %d", rec.Code)
}
}
func TestUserContextMiddlewareDoesNotLetHeaderOverridePathProject(t *testing.T) {
authorizer := fakeShareAuthorizer{access: sharingmodule.Access{
ProjectID: "shared-project",
OwnerID: "d1d2fbbb-97bb-4406-8d0b-ec814cc65092",
Permission: sharingmodule.PermissionEditor,
Capabilities: sharingmodule.PermissionEditor.Capabilities(),
}}
req := httptest.NewRequest(http.MethodGet, "/api/projects/other-project", nil)
req.Header.Set("X-Share-Id", "XhRewKnS4it7X9kEMFLcdVJentg")
req.Header.Set("X-Share-Project-Id", "shared-project")
rec := httptest.NewRecorder()
called := false
UserContextMiddleware(fakeBearerAuthenticator{}, authorizer)(func(http.ResponseWriter, *http.Request) {
called = true
})(rec, req)
if called {
t.Fatal("expected mismatched path project to be blocked")
}
if rec.Code != http.StatusNotFound {
t.Fatalf("expected not found, got %d", rec.Code)
}
}
func TestUserContextMiddlewareRequiresEditorForGeneratorTasks(t *testing.T) {
authorizer := fakeShareAuthorizer{access: sharingmodule.Access{
ProjectID: "project-1",
OwnerID: "d1d2fbbb-97bb-4406-8d0b-ec814cc65092",
Permission: sharingmodule.PermissionViewer,
Capabilities: sharingmodule.PermissionViewer.Capabilities(),
}}
req := httptest.NewRequest(http.MethodGet, "/api/generator/tasks?project_id=project-1&task_id=task-1", nil)
req.Header.Set("X-Share-Id", "XhRewKnS4it7X9kEMFLcdVJentg")
rec := httptest.NewRecorder()
UserContextMiddleware(fakeBearerAuthenticator{}, authorizer)(func(http.ResponseWriter, *http.Request) {
t.Fatal("expected viewer generator access to be blocked")
})(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected forbidden, got %d", rec.Code)
}
}
func TestUserContextMiddlewareProtectsV1Routes(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/generator/tasks", nil)
rec := httptest.NewRecorder()
called := false
UserContextMiddleware(fakeBearerAuthenticator{})(func(http.ResponseWriter, *http.Request) {
called = true
})(rec, req)
if called {
t.Fatal("expected v1 route to require authentication")
}
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected unauthorized, got %d", rec.Code)
}
}
@@ -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,
@@ -0,0 +1,39 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package logic
import (
"context"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type DeleteShareMemberLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewDeleteShareMemberLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteShareMemberLogic {
return &DeleteShareMemberLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *DeleteShareMemberLogic) DeleteShareMember(req *types.DeleteShareMemberRequest) (resp *types.ShareSettingsResponse, err error) {
project, err := ownedShareProject(l.ctx, l.svcCtx, req.Id)
if err != nil {
return nil, err
}
settings, err := l.svcCtx.ShareService.DeleteMember(l.ctx, project.ID, project.UserID, req.MemberId)
if err != nil {
return nil, err
}
return toShareSettingsResponse(l.ctx, l.svcCtx, project, settings), nil
}
@@ -6,6 +6,7 @@ package logic
import (
"context"
sharingmodule "img_infinite_canvas/internal/modules/sharing"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
@@ -31,6 +32,9 @@ func (l *GetProjectLogic) GetProject(req *types.ProjectIdRequest) (resp *types.P
if err != nil {
return nil, err
}
if access, ok := sharingmodule.AccessFromContext(l.ctx); ok {
project = filterSharedProject(project, access)
}
return toProjectResponse(project), nil
}
@@ -0,0 +1,39 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package logic
import (
"context"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type GetShareSettingsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetShareSettingsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetShareSettingsLogic {
return &GetShareSettingsLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetShareSettingsLogic) GetShareSettings(req *types.ShareSettingsRequest) (resp *types.ShareSettingsResponse, err error) {
project, err := ownedShareProject(l.ctx, l.svcCtx, req.Id)
if err != nil {
return nil, err
}
settings, err := l.svcCtx.ShareService.Settings(l.ctx, project.ID, project.UserID)
if err != nil {
return nil, err
}
return toShareSettingsResponse(l.ctx, l.svcCtx, project, settings), nil
}
@@ -0,0 +1,48 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package logic
import (
"context"
sharingmodule "img_infinite_canvas/internal/modules/sharing"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type InviteShareMemberLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewInviteShareMemberLogic(ctx context.Context, svcCtx *svc.ServiceContext) *InviteShareMemberLogic {
return &InviteShareMemberLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *InviteShareMemberLogic) InviteShareMember(req *types.InviteShareMemberRequest) (resp *types.ShareSettingsResponse, err error) {
permission, err := sharingmodule.ParseMemberPermission(req.Permission)
if err != nil {
return nil, err
}
project, err := ownedShareProject(l.ctx, l.svcCtx, req.Id)
if err != nil {
return nil, err
}
owner, err := verifiedShareOwner(l.ctx, l.svcCtx, project)
if err != nil {
return nil, err
}
settings, err := l.svcCtx.ShareService.Invite(l.ctx, project.ID, owner, req.Identifier, permission)
if err != nil {
return nil, err
}
return toShareSettingsResponse(l.ctx, l.svcCtx, project, settings), nil
}
@@ -0,0 +1,39 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package logic
import (
"context"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ListShareVisitorsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListShareVisitorsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListShareVisitorsLogic {
return &ListShareVisitorsLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *ListShareVisitorsLogic) ListShareVisitors(req *types.ShareSettingsRequest) (resp *types.ShareVisitorsResponse, err error) {
project, err := ownedShareProject(l.ctx, l.svcCtx, req.Id)
if err != nil {
return nil, err
}
visitors, err := l.svcCtx.ShareService.Visitors(l.ctx, project.ID, project.UserID)
if err != nil {
return nil, err
}
return toShareVisitorsResponse(l.ctx, l.svcCtx, visitors), nil
}
+13 -5
View File
@@ -9,6 +9,7 @@ import (
"time"
"img_infinite_canvas/internal/domain/design"
sharingmodule "img_infinite_canvas/internal/modules/sharing"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
@@ -37,10 +38,17 @@ func (l *QueryProjectLogic) QueryProject(req *types.QueryProjectRequest) (resp *
return nil, err
}
return toQueryProjectResponse(project), nil
permission := "OWNER"
visibility := "PRIVATE"
if access, ok := sharingmodule.AccessFromContext(l.ctx); ok {
permission = strings.ToUpper(string(access.Permission))
visibility = "SHARED"
project = filterSharedProject(project, access)
}
return toQueryProjectResponse(project, permission, visibility), nil
}
func toQueryProjectResponse(project design.Project) *types.QueryProjectResponse {
func toQueryProjectResponse(project design.Project, permission string, visibility string) *types.QueryProjectResponse {
covers := queryProjectCoverList(project)
updatedAt := formatLovartTime(project.UpdatedAt)
return &types.QueryProjectResponse{
@@ -63,9 +71,9 @@ func toQueryProjectResponse(project design.Project) *types.QueryProjectResponse
UniqId: project.SnapshotID,
ParentUniqId: "",
},
EffectivePermission: "OWNER",
EffectivePermission: permission,
CopyTaskStatus: nil,
Visibility: "PRIVATE",
Visibility: visibility,
CreateTime: updatedAt,
UpdateTime: updatedAt,
},
@@ -78,7 +86,7 @@ func toQueryProjectResponse(project design.Project) *types.QueryProjectResponse
Version: project.Version,
SnapshotId: nil,
ItemId: nil,
Permission: "OWNER",
Permission: permission,
},
}
}
@@ -0,0 +1,57 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package logic
import (
"context"
"time"
"img_infinite_canvas/internal/domain/design"
sharingmodule "img_infinite_canvas/internal/modules/sharing"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ResolveShareLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewResolveShareLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ResolveShareLogic {
return &ResolveShareLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *ResolveShareLogic) ResolveShare(req *types.ResolveShareRequest) (resp *types.ResolveShareResponse, err error) {
actor := sharingmodule.ActorFromContext(l.ctx)
access, err := l.svcCtx.ShareService.ResolveAccess(l.ctx, req.ShareId, actor)
if err != nil {
return nil, err
}
ownerCtx := design.ContextWithUserID(l.ctx, access.OwnerID)
project, err := l.svcCtx.DesignService.GetProject(ownerCtx, access.ProjectID)
if err != nil {
return nil, err
}
if err := l.svcCtx.ShareService.RecordVisit(l.ctx, access, actor, req.VisitorId); err != nil {
l.Errorf("record share visit: %v", err)
}
project = filterSharedProject(project, access)
updatedAt := project.UpdatedAt
if updatedAt.IsZero() {
updatedAt = time.Now().UTC()
}
return &types.ResolveShareResponse{
ShareId: access.ShareID,
Project: toAPIProject(project),
Access: toShareAccess(access),
UpdatedAt: formatTime(updatedAt),
}, nil
}
@@ -0,0 +1,39 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package logic
import (
"context"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type RevokeShareAccessLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewRevokeShareAccessLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RevokeShareAccessLogic {
return &RevokeShareAccessLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *RevokeShareAccessLogic) RevokeShareAccess(req *types.ShareSettingsRequest) (resp *types.ShareSettingsResponse, err error) {
project, err := ownedShareProject(l.ctx, l.svcCtx, req.Id)
if err != nil {
return nil, err
}
settings, err := l.svcCtx.ShareService.RevokeAll(l.ctx, project.ID, project.UserID)
if err != nil {
return nil, err
}
return toShareSettingsResponse(l.ctx, l.svcCtx, project, settings), nil
}
+152
View File
@@ -0,0 +1,152 @@
package logic
import (
"context"
"errors"
"strings"
"img_infinite_canvas/internal/domain/design"
sharingmodule "img_infinite_canvas/internal/modules/sharing"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
)
func ownedShareProject(ctx context.Context, svcCtx *svc.ServiceContext, projectID string) (design.Project, error) {
return svcCtx.DesignService.GetProject(ctx, strings.TrimSpace(projectID))
}
func verifiedShareOwner(ctx context.Context, svcCtx *svc.ServiceContext, project design.Project) (sharingmodule.Actor, error) {
if svcCtx.AuthService == nil {
return sharingmodule.Actor{}, errors.New("auth service is required for share management")
}
profile, err := svcCtx.AuthService.GetProfile(ctx, project.UserID)
if err != nil {
return sharingmodule.Actor{}, err
}
return sharingmodule.Actor{
Authenticated: true,
UserID: project.UserID,
Email: profile.Email,
CountryCode: profile.CountryCode,
Phone: profile.Phone,
Name: profile.Name,
AvatarURL: profile.AvatarURL,
}, nil
}
func toShareSettingsResponse(ctx context.Context, svcCtx *svc.ServiceContext, project design.Project, settings sharingmodule.Settings) *types.ShareSettingsResponse {
owner := types.SharePerson{
Id: project.UserID,
UserId: project.UserID,
Name: "Owner",
Permission: string(sharingmodule.PermissionOwner),
}
ownerActor, ownerErr := verifiedShareOwner(ctx, svcCtx, project)
if ownerErr == nil {
owner.Name = firstShareIdentifier(ownerActor.Name, owner.Name)
owner.AvatarUrl = ownerActor.AvatarURL
owner.Identifier = firstShareIdentifier(ownerActor.Email, displayPhone(ownerActor.CountryCode, ownerActor.Phone))
}
visibleMembers := filterShareMembers(settings.Members, ownerActor)
members := make([]types.SharePerson, 0, len(visibleMembers))
for _, member := range visibleMembers {
members = append(members, types.SharePerson{
Id: member.ID,
Name: member.Identifier,
Identifier: member.Identifier,
Permission: string(member.Permission),
CreatedAt: formatTime(member.CreatedAt),
})
}
return &types.ShareSettingsResponse{Settings: types.ShareSettings{
ProjectId: project.ID,
ShareId: settings.ShareID,
LinkPermission: string(settings.LinkPermission),
Owner: owner,
Members: members,
VisitorCount: settings.VisitorCount,
UpdatedAt: formatTime(settings.UpdatedAt),
}}
}
func filterShareMembers(members []sharingmodule.Member, owner sharingmodule.Actor) []sharingmodule.Member {
if owner.UserID == "" {
return members
}
filtered := make([]sharingmodule.Member, 0, len(members))
for _, member := range members {
if sharingmodule.ActorOwnsIdentifier(owner, member.Identifier) {
continue
}
filtered = append(filtered, member)
}
return filtered
}
func toShareVisitorsResponse(ctx context.Context, svcCtx *svc.ServiceContext, visits []sharingmodule.VisitRecord) *types.ShareVisitorsResponse {
items := make([]types.ShareVisitor, 0, len(visits))
for _, visit := range visits {
item := types.ShareVisitor{
Id: visit.ID,
UserId: visit.UserID,
Name: "Anonymous visitor",
VisitCount: visit.VisitCount,
LastSeenAt: formatTime(visit.LastSeenAt),
}
if visit.UserID != "" && svcCtx.AuthService != nil {
if profile, err := svcCtx.AuthService.GetProfile(ctx, visit.UserID); err == nil {
item.Name = firstShareIdentifier(profile.Name, profile.Email, displayPhone(profile.CountryCode, profile.Phone), "Visitor")
item.Identifier = firstShareIdentifier(profile.Email, displayPhone(profile.CountryCode, profile.Phone))
item.AvatarUrl = profile.AvatarURL
}
}
items = append(items, item)
}
return &types.ShareVisitorsResponse{Visitors: items}
}
func toShareAccess(access sharingmodule.Access) types.ShareAccess {
return types.ShareAccess{
Permission: string(access.Permission),
Source: access.Source,
Authenticated: access.Authenticated,
IsOwner: access.IsOwner,
Capabilities: types.ShareCapabilities{
CanViewCanvas: access.Capabilities.CanViewCanvas,
CanViewChat: access.Capabilities.CanViewChat,
CanEdit: access.Capabilities.CanEdit,
CanManage: access.Capabilities.CanManage,
},
}
}
func filterSharedProject(project design.Project, access sharingmodule.Access) design.Project {
project.UserID = ""
if !access.Capabilities.CanViewChat {
project.Brief = ""
project.LastThreadID = ""
project.Messages = []design.Message{}
}
return project
}
func firstShareIdentifier(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func displayPhone(countryCode string, phone string) string {
phone = strings.TrimSpace(phone)
if phone == "" {
return ""
}
countryCode = strings.TrimPrefix(strings.TrimSpace(countryCode), "+")
if countryCode == "" || strings.HasPrefix(phone, "+") {
return phone
}
return "+" + countryCode + phone
}
@@ -0,0 +1,90 @@
package logic
import (
"context"
"errors"
"testing"
"time"
"img_infinite_canvas/internal/domain/design"
sharingmodule "img_infinite_canvas/internal/modules/sharing"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
)
func TestFilterSharedProjectRedactsCanvasOnlyData(t *testing.T) {
project := design.Project{
ID: "project-1",
UserID: "91cd197b-8255-4172-9981-77391fd38f33",
Brief: "private brief",
LastThreadID: "thread-1",
Messages: []design.Message{{
ID: "message-1",
Content: "private chat",
CreatedAt: time.Now(),
}},
}
canvasAccess := sharingmodule.Access{
Permission: sharingmodule.PermissionCanvas,
Capabilities: sharingmodule.PermissionCanvas.Capabilities(),
}
filtered := filterSharedProject(project, canvasAccess)
if filtered.UserID != "" || filtered.Brief != "" || filtered.LastThreadID != "" || len(filtered.Messages) != 0 {
t.Fatalf("canvas-only response leaked private fields: %+v", filtered)
}
if project.UserID == "" || len(project.Messages) == 0 {
t.Fatal("filter must not mutate the source project")
}
}
func TestSubmitGeneratorTaskRejectsShareProjectMismatch(t *testing.T) {
ctx := sharingmodule.ContextWithAccess(context.Background(), sharingmodule.Access{ProjectID: "shared-project"})
logic := NewSubmitGeneratorTaskLogic(ctx, &svc.ServiceContext{})
_, err := logic.SubmitGeneratorTask(&types.GeneratorTaskSubmitRequest{ProjectId: "other-project"})
if !errors.Is(err, sharingmodule.ErrNotFound) {
t.Fatalf("expected share project mismatch to be rejected, got %v", err)
}
}
func TestFilterSharedProjectKeepsViewerMessages(t *testing.T) {
project := design.Project{
UserID: "91cd197b-8255-4172-9981-77391fd38f33",
Messages: []design.Message{{
ID: "message-1",
Content: "visible chat",
}},
}
viewerAccess := sharingmodule.Access{
Permission: sharingmodule.PermissionViewer,
Capabilities: sharingmodule.PermissionViewer.Capabilities(),
}
filtered := filterSharedProject(project, viewerAccess)
if filtered.UserID != "" {
t.Fatal("shared response must not expose the owner id")
}
if len(filtered.Messages) != 1 || filtered.Messages[0].Content != "visible chat" {
t.Fatalf("viewer should retain chat messages: %+v", filtered.Messages)
}
}
func TestFilterShareMembersRemovesOwnerIdentifiers(t *testing.T) {
owner := sharingmodule.Actor{
Authenticated: true,
UserID: "91cd197b-8255-4172-9981-77391fd38f33",
Email: "owner@example.com",
CountryCode: "+86",
Phone: "13800138000",
}
members := []sharingmodule.Member{
{ID: "email", Identifier: "OWNER@EXAMPLE.COM", Permission: sharingmodule.PermissionEditor},
{ID: "phone", Identifier: "+86 138-0013-8000", Permission: sharingmodule.PermissionViewer},
{ID: "other", Identifier: "member@example.com", Permission: sharingmodule.PermissionViewer},
}
filtered := filterShareMembers(members, owner)
if len(filtered) != 1 || filtered[0].ID != "other" {
t.Fatalf("expected only the non-owner member, got %+v", filtered)
}
}
@@ -7,6 +7,7 @@ import (
"context"
"img_infinite_canvas/internal/domain/design"
sharingmodule "img_infinite_canvas/internal/modules/sharing"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
@@ -28,6 +29,9 @@ func NewSubmitGeneratorTaskLogic(ctx context.Context, svcCtx *svc.ServiceContext
}
func (l *SubmitGeneratorTaskLogic) SubmitGeneratorTask(req *types.GeneratorTaskSubmitRequest) (resp *types.GeneratorTaskSubmitResponse, err error) {
if access, ok := sharingmodule.AccessFromContext(l.ctx); ok && req.ProjectId != access.ProjectID {
return nil, sharingmodule.ErrNotFound
}
task, err := l.svcCtx.DesignService.SubmitGeneratorTask(l.ctx, design.GeneratorTaskSubmitRequest{
CID: req.Cid,
ProjectID: req.ProjectId,
@@ -0,0 +1,44 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package logic
import (
"context"
sharingmodule "img_infinite_canvas/internal/modules/sharing"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type UpdateShareLinkLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUpdateShareLinkLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateShareLinkLogic {
return &UpdateShareLinkLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UpdateShareLinkLogic) UpdateShareLink(req *types.UpdateShareLinkRequest) (resp *types.ShareSettingsResponse, err error) {
permission, err := sharingmodule.ParseLinkPermission(req.Permission)
if err != nil {
return nil, err
}
project, err := ownedShareProject(l.ctx, l.svcCtx, req.Id)
if err != nil {
return nil, err
}
settings, err := l.svcCtx.ShareService.UpdateLink(l.ctx, project.ID, project.UserID, permission)
if err != nil {
return nil, err
}
return toShareSettingsResponse(l.ctx, l.svcCtx, project, settings), nil
}
@@ -0,0 +1,44 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package logic
import (
"context"
sharingmodule "img_infinite_canvas/internal/modules/sharing"
"img_infinite_canvas/internal/svc"
"img_infinite_canvas/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type UpdateShareMemberLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUpdateShareMemberLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateShareMemberLogic {
return &UpdateShareMemberLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UpdateShareMemberLogic) UpdateShareMember(req *types.UpdateShareMemberRequest) (resp *types.ShareSettingsResponse, err error) {
permission, err := sharingmodule.ParseMemberPermission(req.Permission)
if err != nil {
return nil, err
}
project, err := ownedShareProject(l.ctx, l.svcCtx, req.Id)
if err != nil {
return nil, err
}
settings, err := l.svcCtx.ShareService.UpdateMember(l.ctx, project.ID, project.UserID, req.MemberId, permission)
if err != nil {
return nil, err
}
return toShareSettingsResponse(l.ctx, l.svcCtx, project, settings), nil
}
@@ -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
}
+163
View File
@@ -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
}
@@ -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
}
@@ -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}
}
+354
View File
@@ -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
}
@@ -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"]))
}
}
+165
View File
@@ -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
}
+44
View File
@@ -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 {
+91
View File
@@ -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"`