feat(server): add project sharing access control
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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"]))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user