From 3f3133d6e2f404061e147f9dd2424680dc0303a3 Mon Sep 17 00:00:00 2001 From: memoclaw Date: Thu, 19 Mar 2026 23:47:22 +0800 Subject: [PATCH] feat(memo): add share links for private memos (#5742) Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com> --- proto/api/v1/memo_service.proto | 84 ++++ .../v1/apiv1connect/memo_service.connect.go | 126 +++++ proto/gen/api/v1/memo_service.pb.go | 475 +++++++++++++++--- proto/gen/api/v1/memo_service.pb.gw.go | 318 ++++++++++++ proto/gen/api/v1/memo_service_grpc.pb.go | 162 ++++++ proto/gen/openapi.yaml | 142 ++++++ server/router/api/v1/acl_config.go | 3 + server/router/api/v1/connect_services.go | 32 ++ server/router/api/v1/memo_share_service.go | 222 ++++++++ server/router/api/v1/resource_name.go | 1 + .../api/v1/test/memo_share_service_test.go | 109 ++++ server/router/fileserver/fileserver.go | 15 + server/router/fileserver/fileserver_test.go | 168 +++++++ store/db/mysql/memo_share.go | 118 +++++ store/db/postgres/memo_share.go | 136 +++++ store/db/sqlite/memo_share.go | 138 +++++ store/driver.go | 6 + store/memo_share.go | 46 ++ store/migration/mysql/0.27/04__memo_share.sql | 14 + store/migration/mysql/LATEST.sql | 13 + .../postgres/0.27/04__memo_share.sql | 14 + store/migration/postgres/LATEST.sql | 13 + .../migration/sqlite/0.27/04__memo_share.sql | 14 + store/migration/sqlite/LATEST.sql | 13 + .../MemoDetailSidebar/MemoDetailSidebar.tsx | 22 +- web/src/components/MemoSharePanel.tsx | 158 ++++++ web/src/hooks/useMemoShareQueries.ts | 92 ++++ web/src/i18n.ts | 15 +- web/src/locales/en.json | 25 + web/src/pages/SharedMemo.tsx | 73 +++ web/src/router/index.tsx | 4 + web/src/router/routes.ts | 1 + web/src/types/proto/api/v1/memo_service_pb.ts | 185 ++++++- web/src/utils/auth-redirect.ts | 1 + 34 files changed, 2891 insertions(+), 67 deletions(-) create mode 100644 server/router/api/v1/memo_share_service.go create mode 100644 server/router/api/v1/test/memo_share_service_test.go create mode 100644 server/router/fileserver/fileserver_test.go create mode 100644 store/db/mysql/memo_share.go create mode 100644 store/db/postgres/memo_share.go create mode 100644 store/db/sqlite/memo_share.go create mode 100644 store/memo_share.go create mode 100644 store/migration/mysql/0.27/04__memo_share.sql create mode 100644 store/migration/postgres/0.27/04__memo_share.sql create mode 100644 store/migration/sqlite/0.27/04__memo_share.sql create mode 100644 web/src/components/MemoSharePanel.tsx create mode 100644 web/src/hooks/useMemoShareQueries.ts create mode 100644 web/src/pages/SharedMemo.tsx diff --git a/proto/api/v1/memo_service.proto b/proto/api/v1/memo_service.proto index 73d926ef1..9f697d173 100644 --- a/proto/api/v1/memo_service.proto +++ b/proto/api/v1/memo_service.proto @@ -103,6 +103,29 @@ service MemoService { option (google.api.http) = {delete: "/api/v1/{name=memos/*/reactions/*}"}; option (google.api.method_signature) = "name"; } + // CreateMemoShare creates a share link for a memo. Requires authentication as the memo creator. + rpc CreateMemoShare(CreateMemoShareRequest) returns (MemoShare) { + option (google.api.http) = { + post: "/api/v1/{parent=memos/*}/shares" + body: "memo_share" + }; + option (google.api.method_signature) = "parent,memo_share"; + } + // ListMemoShares lists all share links for a memo. Requires authentication as the memo creator. + rpc ListMemoShares(ListMemoSharesRequest) returns (ListMemoSharesResponse) { + option (google.api.http) = {get: "/api/v1/{parent=memos/*}/shares"}; + option (google.api.method_signature) = "parent"; + } + // DeleteMemoShare revokes a share link. Requires authentication as the memo creator. + rpc DeleteMemoShare(DeleteMemoShareRequest) returns (google.protobuf.Empty) { + option (google.api.http) = {delete: "/api/v1/{name=memos/*/shares/*}"}; + option (google.api.method_signature) = "name"; + } + // GetMemoByShare resolves a share token to its memo. No authentication required. + // Returns NOT_FOUND if the token is invalid or expired. + rpc GetMemoByShare(GetMemoByShareRequest) returns (Memo) { + option (google.api.http) = {get: "/api/v1/shares/{share_id}"}; + } } enum Visibility { @@ -511,3 +534,64 @@ message DeleteMemoReactionRequest { (google.api.resource_reference) = {type: "memos.api.v1/Reaction"} ]; } + +// MemoShare is an access grant that permits read-only access to a memo via an opaque bearer token. +message MemoShare { + option (google.api.resource) = { + type: "memos.api.v1/MemoShare" + pattern: "memos/{memo}/shares/{share}" + singular: "share" + plural: "shares" + }; + + // The resource name of the share. Format: memos/{memo}/shares/{share} + // The {share} segment is the opaque token used in the share URL. + string name = 1 [(google.api.field_behavior) = IDENTIFIER]; + + // Output only. When this share link was created. + google.protobuf.Timestamp create_time = 2 [(google.api.field_behavior) = OUTPUT_ONLY]; + + // Optional. When set, the share link stops working after this time. + // If unset, the link never expires. + optional google.protobuf.Timestamp expire_time = 3 [(google.api.field_behavior) = OPTIONAL]; +} + +message CreateMemoShareRequest { + // Required. The resource name of the memo to share. + // Format: memos/{memo} + string parent = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference) = {type: "memos.api.v1/Memo"} + ]; + + // Required. The share to create. + MemoShare memo_share = 2 [(google.api.field_behavior) = REQUIRED]; +} + +message ListMemoSharesRequest { + // Required. The resource name of the memo. + // Format: memos/{memo} + string parent = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference) = {type: "memos.api.v1/Memo"} + ]; +} + +message ListMemoSharesResponse { + // The list of share links. + repeated MemoShare memo_shares = 1; +} + +message DeleteMemoShareRequest { + // Required. The resource name of the share to delete. + // Format: memos/{memo}/shares/{share} + string name = 1 [ + (google.api.field_behavior) = REQUIRED, + (google.api.resource_reference) = {type: "memos.api.v1/MemoShare"} + ]; +} + +message GetMemoByShareRequest { + // Required. The share token extracted from the share URL (/s/{share_id}). + string share_id = 1 [(google.api.field_behavior) = REQUIRED]; +} diff --git a/proto/gen/api/v1/apiv1connect/memo_service.connect.go b/proto/gen/api/v1/apiv1connect/memo_service.connect.go index 6399154a2..1e32fabe9 100644 --- a/proto/gen/api/v1/apiv1connect/memo_service.connect.go +++ b/proto/gen/api/v1/apiv1connect/memo_service.connect.go @@ -71,6 +71,18 @@ const ( // MemoServiceDeleteMemoReactionProcedure is the fully-qualified name of the MemoService's // DeleteMemoReaction RPC. MemoServiceDeleteMemoReactionProcedure = "/memos.api.v1.MemoService/DeleteMemoReaction" + // MemoServiceCreateMemoShareProcedure is the fully-qualified name of the MemoService's + // CreateMemoShare RPC. + MemoServiceCreateMemoShareProcedure = "/memos.api.v1.MemoService/CreateMemoShare" + // MemoServiceListMemoSharesProcedure is the fully-qualified name of the MemoService's + // ListMemoShares RPC. + MemoServiceListMemoSharesProcedure = "/memos.api.v1.MemoService/ListMemoShares" + // MemoServiceDeleteMemoShareProcedure is the fully-qualified name of the MemoService's + // DeleteMemoShare RPC. + MemoServiceDeleteMemoShareProcedure = "/memos.api.v1.MemoService/DeleteMemoShare" + // MemoServiceGetMemoByShareProcedure is the fully-qualified name of the MemoService's + // GetMemoByShare RPC. + MemoServiceGetMemoByShareProcedure = "/memos.api.v1.MemoService/GetMemoByShare" ) // MemoServiceClient is a client for the memos.api.v1.MemoService service. @@ -103,6 +115,15 @@ type MemoServiceClient interface { UpsertMemoReaction(context.Context, *connect.Request[v1.UpsertMemoReactionRequest]) (*connect.Response[v1.Reaction], error) // DeleteMemoReaction deletes a reaction for a memo. DeleteMemoReaction(context.Context, *connect.Request[v1.DeleteMemoReactionRequest]) (*connect.Response[emptypb.Empty], error) + // CreateMemoShare creates a share link for a memo. Requires authentication as the memo creator. + CreateMemoShare(context.Context, *connect.Request[v1.CreateMemoShareRequest]) (*connect.Response[v1.MemoShare], error) + // ListMemoShares lists all share links for a memo. Requires authentication as the memo creator. + ListMemoShares(context.Context, *connect.Request[v1.ListMemoSharesRequest]) (*connect.Response[v1.ListMemoSharesResponse], error) + // DeleteMemoShare revokes a share link. Requires authentication as the memo creator. + DeleteMemoShare(context.Context, *connect.Request[v1.DeleteMemoShareRequest]) (*connect.Response[emptypb.Empty], error) + // GetMemoByShare resolves a share token to its memo. No authentication required. + // Returns NOT_FOUND if the token is invalid or expired. + GetMemoByShare(context.Context, *connect.Request[v1.GetMemoByShareRequest]) (*connect.Response[v1.Memo], error) } // NewMemoServiceClient constructs a client for the memos.api.v1.MemoService service. By default, it @@ -200,6 +221,30 @@ func NewMemoServiceClient(httpClient connect.HTTPClient, baseURL string, opts .. connect.WithSchema(memoServiceMethods.ByName("DeleteMemoReaction")), connect.WithClientOptions(opts...), ), + createMemoShare: connect.NewClient[v1.CreateMemoShareRequest, v1.MemoShare]( + httpClient, + baseURL+MemoServiceCreateMemoShareProcedure, + connect.WithSchema(memoServiceMethods.ByName("CreateMemoShare")), + connect.WithClientOptions(opts...), + ), + listMemoShares: connect.NewClient[v1.ListMemoSharesRequest, v1.ListMemoSharesResponse]( + httpClient, + baseURL+MemoServiceListMemoSharesProcedure, + connect.WithSchema(memoServiceMethods.ByName("ListMemoShares")), + connect.WithClientOptions(opts...), + ), + deleteMemoShare: connect.NewClient[v1.DeleteMemoShareRequest, emptypb.Empty]( + httpClient, + baseURL+MemoServiceDeleteMemoShareProcedure, + connect.WithSchema(memoServiceMethods.ByName("DeleteMemoShare")), + connect.WithClientOptions(opts...), + ), + getMemoByShare: connect.NewClient[v1.GetMemoByShareRequest, v1.Memo]( + httpClient, + baseURL+MemoServiceGetMemoByShareProcedure, + connect.WithSchema(memoServiceMethods.ByName("GetMemoByShare")), + connect.WithClientOptions(opts...), + ), } } @@ -219,6 +264,10 @@ type memoServiceClient struct { listMemoReactions *connect.Client[v1.ListMemoReactionsRequest, v1.ListMemoReactionsResponse] upsertMemoReaction *connect.Client[v1.UpsertMemoReactionRequest, v1.Reaction] deleteMemoReaction *connect.Client[v1.DeleteMemoReactionRequest, emptypb.Empty] + createMemoShare *connect.Client[v1.CreateMemoShareRequest, v1.MemoShare] + listMemoShares *connect.Client[v1.ListMemoSharesRequest, v1.ListMemoSharesResponse] + deleteMemoShare *connect.Client[v1.DeleteMemoShareRequest, emptypb.Empty] + getMemoByShare *connect.Client[v1.GetMemoByShareRequest, v1.Memo] } // CreateMemo calls memos.api.v1.MemoService.CreateMemo. @@ -291,6 +340,26 @@ func (c *memoServiceClient) DeleteMemoReaction(ctx context.Context, req *connect return c.deleteMemoReaction.CallUnary(ctx, req) } +// CreateMemoShare calls memos.api.v1.MemoService.CreateMemoShare. +func (c *memoServiceClient) CreateMemoShare(ctx context.Context, req *connect.Request[v1.CreateMemoShareRequest]) (*connect.Response[v1.MemoShare], error) { + return c.createMemoShare.CallUnary(ctx, req) +} + +// ListMemoShares calls memos.api.v1.MemoService.ListMemoShares. +func (c *memoServiceClient) ListMemoShares(ctx context.Context, req *connect.Request[v1.ListMemoSharesRequest]) (*connect.Response[v1.ListMemoSharesResponse], error) { + return c.listMemoShares.CallUnary(ctx, req) +} + +// DeleteMemoShare calls memos.api.v1.MemoService.DeleteMemoShare. +func (c *memoServiceClient) DeleteMemoShare(ctx context.Context, req *connect.Request[v1.DeleteMemoShareRequest]) (*connect.Response[emptypb.Empty], error) { + return c.deleteMemoShare.CallUnary(ctx, req) +} + +// GetMemoByShare calls memos.api.v1.MemoService.GetMemoByShare. +func (c *memoServiceClient) GetMemoByShare(ctx context.Context, req *connect.Request[v1.GetMemoByShareRequest]) (*connect.Response[v1.Memo], error) { + return c.getMemoByShare.CallUnary(ctx, req) +} + // MemoServiceHandler is an implementation of the memos.api.v1.MemoService service. type MemoServiceHandler interface { // CreateMemo creates a memo. @@ -321,6 +390,15 @@ type MemoServiceHandler interface { UpsertMemoReaction(context.Context, *connect.Request[v1.UpsertMemoReactionRequest]) (*connect.Response[v1.Reaction], error) // DeleteMemoReaction deletes a reaction for a memo. DeleteMemoReaction(context.Context, *connect.Request[v1.DeleteMemoReactionRequest]) (*connect.Response[emptypb.Empty], error) + // CreateMemoShare creates a share link for a memo. Requires authentication as the memo creator. + CreateMemoShare(context.Context, *connect.Request[v1.CreateMemoShareRequest]) (*connect.Response[v1.MemoShare], error) + // ListMemoShares lists all share links for a memo. Requires authentication as the memo creator. + ListMemoShares(context.Context, *connect.Request[v1.ListMemoSharesRequest]) (*connect.Response[v1.ListMemoSharesResponse], error) + // DeleteMemoShare revokes a share link. Requires authentication as the memo creator. + DeleteMemoShare(context.Context, *connect.Request[v1.DeleteMemoShareRequest]) (*connect.Response[emptypb.Empty], error) + // GetMemoByShare resolves a share token to its memo. No authentication required. + // Returns NOT_FOUND if the token is invalid or expired. + GetMemoByShare(context.Context, *connect.Request[v1.GetMemoByShareRequest]) (*connect.Response[v1.Memo], error) } // NewMemoServiceHandler builds an HTTP handler from the service implementation. It returns the path @@ -414,6 +492,30 @@ func NewMemoServiceHandler(svc MemoServiceHandler, opts ...connect.HandlerOption connect.WithSchema(memoServiceMethods.ByName("DeleteMemoReaction")), connect.WithHandlerOptions(opts...), ) + memoServiceCreateMemoShareHandler := connect.NewUnaryHandler( + MemoServiceCreateMemoShareProcedure, + svc.CreateMemoShare, + connect.WithSchema(memoServiceMethods.ByName("CreateMemoShare")), + connect.WithHandlerOptions(opts...), + ) + memoServiceListMemoSharesHandler := connect.NewUnaryHandler( + MemoServiceListMemoSharesProcedure, + svc.ListMemoShares, + connect.WithSchema(memoServiceMethods.ByName("ListMemoShares")), + connect.WithHandlerOptions(opts...), + ) + memoServiceDeleteMemoShareHandler := connect.NewUnaryHandler( + MemoServiceDeleteMemoShareProcedure, + svc.DeleteMemoShare, + connect.WithSchema(memoServiceMethods.ByName("DeleteMemoShare")), + connect.WithHandlerOptions(opts...), + ) + memoServiceGetMemoByShareHandler := connect.NewUnaryHandler( + MemoServiceGetMemoByShareProcedure, + svc.GetMemoByShare, + connect.WithSchema(memoServiceMethods.ByName("GetMemoByShare")), + connect.WithHandlerOptions(opts...), + ) return "/memos.api.v1.MemoService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case MemoServiceCreateMemoProcedure: @@ -444,6 +546,14 @@ func NewMemoServiceHandler(svc MemoServiceHandler, opts ...connect.HandlerOption memoServiceUpsertMemoReactionHandler.ServeHTTP(w, r) case MemoServiceDeleteMemoReactionProcedure: memoServiceDeleteMemoReactionHandler.ServeHTTP(w, r) + case MemoServiceCreateMemoShareProcedure: + memoServiceCreateMemoShareHandler.ServeHTTP(w, r) + case MemoServiceListMemoSharesProcedure: + memoServiceListMemoSharesHandler.ServeHTTP(w, r) + case MemoServiceDeleteMemoShareProcedure: + memoServiceDeleteMemoShareHandler.ServeHTTP(w, r) + case MemoServiceGetMemoByShareProcedure: + memoServiceGetMemoByShareHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -508,3 +618,19 @@ func (UnimplementedMemoServiceHandler) UpsertMemoReaction(context.Context, *conn func (UnimplementedMemoServiceHandler) DeleteMemoReaction(context.Context, *connect.Request[v1.DeleteMemoReactionRequest]) (*connect.Response[emptypb.Empty], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("memos.api.v1.MemoService.DeleteMemoReaction is not implemented")) } + +func (UnimplementedMemoServiceHandler) CreateMemoShare(context.Context, *connect.Request[v1.CreateMemoShareRequest]) (*connect.Response[v1.MemoShare], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("memos.api.v1.MemoService.CreateMemoShare is not implemented")) +} + +func (UnimplementedMemoServiceHandler) ListMemoShares(context.Context, *connect.Request[v1.ListMemoSharesRequest]) (*connect.Response[v1.ListMemoSharesResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("memos.api.v1.MemoService.ListMemoShares is not implemented")) +} + +func (UnimplementedMemoServiceHandler) DeleteMemoShare(context.Context, *connect.Request[v1.DeleteMemoShareRequest]) (*connect.Response[emptypb.Empty], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("memos.api.v1.MemoService.DeleteMemoShare is not implemented")) +} + +func (UnimplementedMemoServiceHandler) GetMemoByShare(context.Context, *connect.Request[v1.GetMemoByShareRequest]) (*connect.Response[v1.Memo], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("memos.api.v1.MemoService.GetMemoByShare is not implemented")) +} diff --git a/proto/gen/api/v1/memo_service.pb.go b/proto/gen/api/v1/memo_service.pb.go index 2b561de0d..e56ad14e2 100644 --- a/proto/gen/api/v1/memo_service.pb.go +++ b/proto/gen/api/v1/memo_service.pb.go @@ -1672,6 +1672,309 @@ func (x *DeleteMemoReactionRequest) GetName() string { return "" } +// MemoShare is an access grant that permits read-only access to a memo via an opaque bearer token. +type MemoShare struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The resource name of the share. Format: memos/{memo}/shares/{share} + // The {share} segment is the opaque token used in the share URL. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // Output only. When this share link was created. + CreateTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty"` + // Optional. When set, the share link stops working after this time. + // If unset, the link never expires. + ExpireTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=expire_time,json=expireTime,proto3,oneof" json:"expire_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MemoShare) Reset() { + *x = MemoShare{} + mi := &file_api_v1_memo_service_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MemoShare) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MemoShare) ProtoMessage() {} + +func (x *MemoShare) ProtoReflect() protoreflect.Message { + mi := &file_api_v1_memo_service_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MemoShare.ProtoReflect.Descriptor instead. +func (*MemoShare) Descriptor() ([]byte, []int) { + return file_api_v1_memo_service_proto_rawDescGZIP(), []int{23} +} + +func (x *MemoShare) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *MemoShare) GetCreateTime() *timestamppb.Timestamp { + if x != nil { + return x.CreateTime + } + return nil +} + +func (x *MemoShare) GetExpireTime() *timestamppb.Timestamp { + if x != nil { + return x.ExpireTime + } + return nil +} + +type CreateMemoShareRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Required. The resource name of the memo to share. + // Format: memos/{memo} + Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"` + // Required. The share to create. + MemoShare *MemoShare `protobuf:"bytes,2,opt,name=memo_share,json=memoShare,proto3" json:"memo_share,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateMemoShareRequest) Reset() { + *x = CreateMemoShareRequest{} + mi := &file_api_v1_memo_service_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateMemoShareRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateMemoShareRequest) ProtoMessage() {} + +func (x *CreateMemoShareRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_v1_memo_service_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateMemoShareRequest.ProtoReflect.Descriptor instead. +func (*CreateMemoShareRequest) Descriptor() ([]byte, []int) { + return file_api_v1_memo_service_proto_rawDescGZIP(), []int{24} +} + +func (x *CreateMemoShareRequest) GetParent() string { + if x != nil { + return x.Parent + } + return "" +} + +func (x *CreateMemoShareRequest) GetMemoShare() *MemoShare { + if x != nil { + return x.MemoShare + } + return nil +} + +type ListMemoSharesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Required. The resource name of the memo. + // Format: memos/{memo} + Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListMemoSharesRequest) Reset() { + *x = ListMemoSharesRequest{} + mi := &file_api_v1_memo_service_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListMemoSharesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListMemoSharesRequest) ProtoMessage() {} + +func (x *ListMemoSharesRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_v1_memo_service_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListMemoSharesRequest.ProtoReflect.Descriptor instead. +func (*ListMemoSharesRequest) Descriptor() ([]byte, []int) { + return file_api_v1_memo_service_proto_rawDescGZIP(), []int{25} +} + +func (x *ListMemoSharesRequest) GetParent() string { + if x != nil { + return x.Parent + } + return "" +} + +type ListMemoSharesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The list of share links. + MemoShares []*MemoShare `protobuf:"bytes,1,rep,name=memo_shares,json=memoShares,proto3" json:"memo_shares,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListMemoSharesResponse) Reset() { + *x = ListMemoSharesResponse{} + mi := &file_api_v1_memo_service_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListMemoSharesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListMemoSharesResponse) ProtoMessage() {} + +func (x *ListMemoSharesResponse) ProtoReflect() protoreflect.Message { + mi := &file_api_v1_memo_service_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListMemoSharesResponse.ProtoReflect.Descriptor instead. +func (*ListMemoSharesResponse) Descriptor() ([]byte, []int) { + return file_api_v1_memo_service_proto_rawDescGZIP(), []int{26} +} + +func (x *ListMemoSharesResponse) GetMemoShares() []*MemoShare { + if x != nil { + return x.MemoShares + } + return nil +} + +type DeleteMemoShareRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Required. The resource name of the share to delete. + // Format: memos/{memo}/shares/{share} + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteMemoShareRequest) Reset() { + *x = DeleteMemoShareRequest{} + mi := &file_api_v1_memo_service_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteMemoShareRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteMemoShareRequest) ProtoMessage() {} + +func (x *DeleteMemoShareRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_v1_memo_service_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteMemoShareRequest.ProtoReflect.Descriptor instead. +func (*DeleteMemoShareRequest) Descriptor() ([]byte, []int) { + return file_api_v1_memo_service_proto_rawDescGZIP(), []int{27} +} + +func (x *DeleteMemoShareRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type GetMemoByShareRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Required. The share token extracted from the share URL (/s/{share_id}). + ShareId string `protobuf:"bytes,1,opt,name=share_id,json=shareId,proto3" json:"share_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetMemoByShareRequest) Reset() { + *x = GetMemoByShareRequest{} + mi := &file_api_v1_memo_service_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetMemoByShareRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetMemoByShareRequest) ProtoMessage() {} + +func (x *GetMemoByShareRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_v1_memo_service_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetMemoByShareRequest.ProtoReflect.Descriptor instead. +func (*GetMemoByShareRequest) Descriptor() ([]byte, []int) { + return file_api_v1_memo_service_proto_rawDescGZIP(), []int{28} +} + +func (x *GetMemoByShareRequest) GetShareId() string { + if x != nil { + return x.ShareId + } + return "" +} + // Computed properties of a memo. type Memo_Property struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1687,7 +1990,7 @@ type Memo_Property struct { func (x *Memo_Property) Reset() { *x = Memo_Property{} - mi := &file_api_v1_memo_service_proto_msgTypes[23] + mi := &file_api_v1_memo_service_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1699,7 +2002,7 @@ func (x *Memo_Property) String() string { func (*Memo_Property) ProtoMessage() {} func (x *Memo_Property) ProtoReflect() protoreflect.Message { - mi := &file_api_v1_memo_service_proto_msgTypes[23] + mi := &file_api_v1_memo_service_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1764,7 +2067,7 @@ type MemoRelation_Memo struct { func (x *MemoRelation_Memo) Reset() { *x = MemoRelation_Memo{} - mi := &file_api_v1_memo_service_proto_msgTypes[24] + mi := &file_api_v1_memo_service_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1776,7 +2079,7 @@ func (x *MemoRelation_Memo) String() string { func (*MemoRelation_Memo) ProtoMessage() {} func (x *MemoRelation_Memo) ProtoReflect() protoreflect.Message { - mi := &file_api_v1_memo_service_proto_msgTypes[24] + mi := &file_api_v1_memo_service_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1958,14 +2261,38 @@ const file_api_v1_memo_service_proto_rawDesc = "" + "\breaction\x18\x02 \x01(\v2\x16.memos.api.v1.ReactionB\x03\xe0A\x02R\breaction\"N\n" + "\x19DeleteMemoReactionRequest\x121\n" + "\x04name\x18\x01 \x01(\tB\x1d\xe0A\x02\xfaA\x17\n" + - "\x15memos.api.v1/ReactionR\x04name*P\n" + + "\x15memos.api.v1/ReactionR\x04name\"\x86\x02\n" + + "\tMemoShare\x12\x17\n" + + "\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12@\n" + + "\vcreate_time\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x03R\n" + + "createTime\x12E\n" + + "\vexpire_time\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampB\x03\xe0A\x01H\x00R\n" + + "expireTime\x88\x01\x01:G\xeaAD\n" + + "\x16memos.api.v1/MemoShare\x12\x1bmemos/{memo}/shares/{share}*\x06shares2\x05shareB\x0e\n" + + "\f_expire_time\"\x88\x01\n" + + "\x16CreateMemoShareRequest\x121\n" + + "\x06parent\x18\x01 \x01(\tB\x19\xe0A\x02\xfaA\x13\n" + + "\x11memos.api.v1/MemoR\x06parent\x12;\n" + + "\n" + + "memo_share\x18\x02 \x01(\v2\x17.memos.api.v1.MemoShareB\x03\xe0A\x02R\tmemoShare\"J\n" + + "\x15ListMemoSharesRequest\x121\n" + + "\x06parent\x18\x01 \x01(\tB\x19\xe0A\x02\xfaA\x13\n" + + "\x11memos.api.v1/MemoR\x06parent\"R\n" + + "\x16ListMemoSharesResponse\x128\n" + + "\vmemo_shares\x18\x01 \x03(\v2\x17.memos.api.v1.MemoShareR\n" + + "memoShares\"L\n" + + "\x16DeleteMemoShareRequest\x122\n" + + "\x04name\x18\x01 \x01(\tB\x1e\xe0A\x02\xfaA\x18\n" + + "\x16memos.api.v1/MemoShareR\x04name\"7\n" + + "\x15GetMemoByShareRequest\x12\x1e\n" + + "\bshare_id\x18\x01 \x01(\tB\x03\xe0A\x02R\ashareId*P\n" + "\n" + "Visibility\x12\x1a\n" + "\x16VISIBILITY_UNSPECIFIED\x10\x00\x12\v\n" + "\aPRIVATE\x10\x01\x12\r\n" + "\tPROTECTED\x10\x02\x12\n" + "\n" + - "\x06PUBLIC\x10\x032\xd3\x0e\n" + + "\x06PUBLIC\x10\x032\xee\x12\n" + "\vMemoService\x12e\n" + "\n" + "CreateMemo\x12\x1f.memos.api.v1.CreateMemoRequest\x1a\x12.memos.api.v1.Memo\"\"\xdaA\x04memo\x82\xd3\xe4\x93\x02\x15:\x04memo\"\r/api/v1/memos\x12f\n" + @@ -1983,7 +2310,12 @@ const file_api_v1_memo_service_proto_rawDesc = "" + "\x10ListMemoComments\x12%.memos.api.v1.ListMemoCommentsRequest\x1a&.memos.api.v1.ListMemoCommentsResponse\".\xdaA\x04name\x82\xd3\xe4\x93\x02!\x12\x1f/api/v1/{name=memos/*}/comments\x12\x95\x01\n" + "\x11ListMemoReactions\x12&.memos.api.v1.ListMemoReactionsRequest\x1a'.memos.api.v1.ListMemoReactionsResponse\"/\xdaA\x04name\x82\xd3\xe4\x93\x02\"\x12 /api/v1/{name=memos/*}/reactions\x12\x89\x01\n" + "\x12UpsertMemoReaction\x12'.memos.api.v1.UpsertMemoReactionRequest\x1a\x16.memos.api.v1.Reaction\"2\xdaA\x04name\x82\xd3\xe4\x93\x02%:\x01*\" /api/v1/{name=memos/*}/reactions\x12\x88\x01\n" + - "\x12DeleteMemoReaction\x12'.memos.api.v1.DeleteMemoReactionRequest\x1a\x16.google.protobuf.Empty\"1\xdaA\x04name\x82\xd3\xe4\x93\x02$*\"/api/v1/{name=memos/*/reactions/*}B\xa8\x01\n" + + "\x12DeleteMemoReaction\x12'.memos.api.v1.DeleteMemoReactionRequest\x1a\x16.google.protobuf.Empty\"1\xdaA\x04name\x82\xd3\xe4\x93\x02$*\"/api/v1/{name=memos/*/reactions/*}\x12\x99\x01\n" + + "\x0fCreateMemoShare\x12$.memos.api.v1.CreateMemoShareRequest\x1a\x17.memos.api.v1.MemoShare\"G\xdaA\x11parent,memo_share\x82\xd3\xe4\x93\x02-:\n" + + "memo_share\"\x1f/api/v1/{parent=memos/*}/shares\x12\x8d\x01\n" + + "\x0eListMemoShares\x12#.memos.api.v1.ListMemoSharesRequest\x1a$.memos.api.v1.ListMemoSharesResponse\"0\xdaA\x06parent\x82\xd3\xe4\x93\x02!\x12\x1f/api/v1/{parent=memos/*}/shares\x12\x7f\n" + + "\x0fDeleteMemoShare\x12$.memos.api.v1.DeleteMemoShareRequest\x1a\x16.google.protobuf.Empty\".\xdaA\x04name\x82\xd3\xe4\x93\x02!*\x1f/api/v1/{name=memos/*/shares/*}\x12l\n" + + "\x0eGetMemoByShare\x12#.memos.api.v1.GetMemoByShareRequest\x1a\x12.memos.api.v1.Memo\"!\x82\xd3\xe4\x93\x02\x1b\x12\x19/api/v1/shares/{share_id}B\xa8\x01\n" + "\x10com.memos.api.v1B\x10MemoServiceProtoP\x01Z0github.com/usememos/memos/proto/gen/api/v1;apiv1\xa2\x02\x03MAX\xaa\x02\fMemos.Api.V1\xca\x02\fMemos\\Api\\V1\xe2\x02\x18Memos\\Api\\V1\\GPBMetadata\xea\x02\x0eMemos::Api::V1b\x06proto3" var ( @@ -1999,7 +2331,7 @@ func file_api_v1_memo_service_proto_rawDescGZIP() []byte { } var file_api_v1_memo_service_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_api_v1_memo_service_proto_msgTypes = make([]protoimpl.MessageInfo, 25) +var file_api_v1_memo_service_proto_msgTypes = make([]protoimpl.MessageInfo, 31) var file_api_v1_memo_service_proto_goTypes = []any{ (Visibility)(0), // 0: memos.api.v1.Visibility (MemoRelation_Type)(0), // 1: memos.api.v1.MemoRelation.Type @@ -2026,35 +2358,41 @@ var file_api_v1_memo_service_proto_goTypes = []any{ (*ListMemoReactionsResponse)(nil), // 22: memos.api.v1.ListMemoReactionsResponse (*UpsertMemoReactionRequest)(nil), // 23: memos.api.v1.UpsertMemoReactionRequest (*DeleteMemoReactionRequest)(nil), // 24: memos.api.v1.DeleteMemoReactionRequest - (*Memo_Property)(nil), // 25: memos.api.v1.Memo.Property - (*MemoRelation_Memo)(nil), // 26: memos.api.v1.MemoRelation.Memo - (*timestamppb.Timestamp)(nil), // 27: google.protobuf.Timestamp - (State)(0), // 28: memos.api.v1.State - (*Attachment)(nil), // 29: memos.api.v1.Attachment - (*fieldmaskpb.FieldMask)(nil), // 30: google.protobuf.FieldMask - (*emptypb.Empty)(nil), // 31: google.protobuf.Empty + (*MemoShare)(nil), // 25: memos.api.v1.MemoShare + (*CreateMemoShareRequest)(nil), // 26: memos.api.v1.CreateMemoShareRequest + (*ListMemoSharesRequest)(nil), // 27: memos.api.v1.ListMemoSharesRequest + (*ListMemoSharesResponse)(nil), // 28: memos.api.v1.ListMemoSharesResponse + (*DeleteMemoShareRequest)(nil), // 29: memos.api.v1.DeleteMemoShareRequest + (*GetMemoByShareRequest)(nil), // 30: memos.api.v1.GetMemoByShareRequest + (*Memo_Property)(nil), // 31: memos.api.v1.Memo.Property + (*MemoRelation_Memo)(nil), // 32: memos.api.v1.MemoRelation.Memo + (*timestamppb.Timestamp)(nil), // 33: google.protobuf.Timestamp + (State)(0), // 34: memos.api.v1.State + (*Attachment)(nil), // 35: memos.api.v1.Attachment + (*fieldmaskpb.FieldMask)(nil), // 36: google.protobuf.FieldMask + (*emptypb.Empty)(nil), // 37: google.protobuf.Empty } var file_api_v1_memo_service_proto_depIdxs = []int32{ - 27, // 0: memos.api.v1.Reaction.create_time:type_name -> google.protobuf.Timestamp - 28, // 1: memos.api.v1.Memo.state:type_name -> memos.api.v1.State - 27, // 2: memos.api.v1.Memo.create_time:type_name -> google.protobuf.Timestamp - 27, // 3: memos.api.v1.Memo.update_time:type_name -> google.protobuf.Timestamp - 27, // 4: memos.api.v1.Memo.display_time:type_name -> google.protobuf.Timestamp + 33, // 0: memos.api.v1.Reaction.create_time:type_name -> google.protobuf.Timestamp + 34, // 1: memos.api.v1.Memo.state:type_name -> memos.api.v1.State + 33, // 2: memos.api.v1.Memo.create_time:type_name -> google.protobuf.Timestamp + 33, // 3: memos.api.v1.Memo.update_time:type_name -> google.protobuf.Timestamp + 33, // 4: memos.api.v1.Memo.display_time:type_name -> google.protobuf.Timestamp 0, // 5: memos.api.v1.Memo.visibility:type_name -> memos.api.v1.Visibility - 29, // 6: memos.api.v1.Memo.attachments:type_name -> memos.api.v1.Attachment + 35, // 6: memos.api.v1.Memo.attachments:type_name -> memos.api.v1.Attachment 14, // 7: memos.api.v1.Memo.relations:type_name -> memos.api.v1.MemoRelation 2, // 8: memos.api.v1.Memo.reactions:type_name -> memos.api.v1.Reaction - 25, // 9: memos.api.v1.Memo.property:type_name -> memos.api.v1.Memo.Property + 31, // 9: memos.api.v1.Memo.property:type_name -> memos.api.v1.Memo.Property 4, // 10: memos.api.v1.Memo.location:type_name -> memos.api.v1.Location 3, // 11: memos.api.v1.CreateMemoRequest.memo:type_name -> memos.api.v1.Memo - 28, // 12: memos.api.v1.ListMemosRequest.state:type_name -> memos.api.v1.State + 34, // 12: memos.api.v1.ListMemosRequest.state:type_name -> memos.api.v1.State 3, // 13: memos.api.v1.ListMemosResponse.memos:type_name -> memos.api.v1.Memo 3, // 14: memos.api.v1.UpdateMemoRequest.memo:type_name -> memos.api.v1.Memo - 30, // 15: memos.api.v1.UpdateMemoRequest.update_mask:type_name -> google.protobuf.FieldMask - 29, // 16: memos.api.v1.SetMemoAttachmentsRequest.attachments:type_name -> memos.api.v1.Attachment - 29, // 17: memos.api.v1.ListMemoAttachmentsResponse.attachments:type_name -> memos.api.v1.Attachment - 26, // 18: memos.api.v1.MemoRelation.memo:type_name -> memos.api.v1.MemoRelation.Memo - 26, // 19: memos.api.v1.MemoRelation.related_memo:type_name -> memos.api.v1.MemoRelation.Memo + 36, // 15: memos.api.v1.UpdateMemoRequest.update_mask:type_name -> google.protobuf.FieldMask + 35, // 16: memos.api.v1.SetMemoAttachmentsRequest.attachments:type_name -> memos.api.v1.Attachment + 35, // 17: memos.api.v1.ListMemoAttachmentsResponse.attachments:type_name -> memos.api.v1.Attachment + 32, // 18: memos.api.v1.MemoRelation.memo:type_name -> memos.api.v1.MemoRelation.Memo + 32, // 19: memos.api.v1.MemoRelation.related_memo:type_name -> memos.api.v1.MemoRelation.Memo 1, // 20: memos.api.v1.MemoRelation.type:type_name -> memos.api.v1.MemoRelation.Type 14, // 21: memos.api.v1.SetMemoRelationsRequest.relations:type_name -> memos.api.v1.MemoRelation 14, // 22: memos.api.v1.ListMemoRelationsResponse.relations:type_name -> memos.api.v1.MemoRelation @@ -2062,39 +2400,51 @@ var file_api_v1_memo_service_proto_depIdxs = []int32{ 3, // 24: memos.api.v1.ListMemoCommentsResponse.memos:type_name -> memos.api.v1.Memo 2, // 25: memos.api.v1.ListMemoReactionsResponse.reactions:type_name -> memos.api.v1.Reaction 2, // 26: memos.api.v1.UpsertMemoReactionRequest.reaction:type_name -> memos.api.v1.Reaction - 5, // 27: memos.api.v1.MemoService.CreateMemo:input_type -> memos.api.v1.CreateMemoRequest - 6, // 28: memos.api.v1.MemoService.ListMemos:input_type -> memos.api.v1.ListMemosRequest - 8, // 29: memos.api.v1.MemoService.GetMemo:input_type -> memos.api.v1.GetMemoRequest - 9, // 30: memos.api.v1.MemoService.UpdateMemo:input_type -> memos.api.v1.UpdateMemoRequest - 10, // 31: memos.api.v1.MemoService.DeleteMemo:input_type -> memos.api.v1.DeleteMemoRequest - 11, // 32: memos.api.v1.MemoService.SetMemoAttachments:input_type -> memos.api.v1.SetMemoAttachmentsRequest - 12, // 33: memos.api.v1.MemoService.ListMemoAttachments:input_type -> memos.api.v1.ListMemoAttachmentsRequest - 15, // 34: memos.api.v1.MemoService.SetMemoRelations:input_type -> memos.api.v1.SetMemoRelationsRequest - 16, // 35: memos.api.v1.MemoService.ListMemoRelations:input_type -> memos.api.v1.ListMemoRelationsRequest - 18, // 36: memos.api.v1.MemoService.CreateMemoComment:input_type -> memos.api.v1.CreateMemoCommentRequest - 19, // 37: memos.api.v1.MemoService.ListMemoComments:input_type -> memos.api.v1.ListMemoCommentsRequest - 21, // 38: memos.api.v1.MemoService.ListMemoReactions:input_type -> memos.api.v1.ListMemoReactionsRequest - 23, // 39: memos.api.v1.MemoService.UpsertMemoReaction:input_type -> memos.api.v1.UpsertMemoReactionRequest - 24, // 40: memos.api.v1.MemoService.DeleteMemoReaction:input_type -> memos.api.v1.DeleteMemoReactionRequest - 3, // 41: memos.api.v1.MemoService.CreateMemo:output_type -> memos.api.v1.Memo - 7, // 42: memos.api.v1.MemoService.ListMemos:output_type -> memos.api.v1.ListMemosResponse - 3, // 43: memos.api.v1.MemoService.GetMemo:output_type -> memos.api.v1.Memo - 3, // 44: memos.api.v1.MemoService.UpdateMemo:output_type -> memos.api.v1.Memo - 31, // 45: memos.api.v1.MemoService.DeleteMemo:output_type -> google.protobuf.Empty - 31, // 46: memos.api.v1.MemoService.SetMemoAttachments:output_type -> google.protobuf.Empty - 13, // 47: memos.api.v1.MemoService.ListMemoAttachments:output_type -> memos.api.v1.ListMemoAttachmentsResponse - 31, // 48: memos.api.v1.MemoService.SetMemoRelations:output_type -> google.protobuf.Empty - 17, // 49: memos.api.v1.MemoService.ListMemoRelations:output_type -> memos.api.v1.ListMemoRelationsResponse - 3, // 50: memos.api.v1.MemoService.CreateMemoComment:output_type -> memos.api.v1.Memo - 20, // 51: memos.api.v1.MemoService.ListMemoComments:output_type -> memos.api.v1.ListMemoCommentsResponse - 22, // 52: memos.api.v1.MemoService.ListMemoReactions:output_type -> memos.api.v1.ListMemoReactionsResponse - 2, // 53: memos.api.v1.MemoService.UpsertMemoReaction:output_type -> memos.api.v1.Reaction - 31, // 54: memos.api.v1.MemoService.DeleteMemoReaction:output_type -> google.protobuf.Empty - 41, // [41:55] is the sub-list for method output_type - 27, // [27:41] is the sub-list for method input_type - 27, // [27:27] is the sub-list for extension type_name - 27, // [27:27] is the sub-list for extension extendee - 0, // [0:27] is the sub-list for field type_name + 33, // 27: memos.api.v1.MemoShare.create_time:type_name -> google.protobuf.Timestamp + 33, // 28: memos.api.v1.MemoShare.expire_time:type_name -> google.protobuf.Timestamp + 25, // 29: memos.api.v1.CreateMemoShareRequest.memo_share:type_name -> memos.api.v1.MemoShare + 25, // 30: memos.api.v1.ListMemoSharesResponse.memo_shares:type_name -> memos.api.v1.MemoShare + 5, // 31: memos.api.v1.MemoService.CreateMemo:input_type -> memos.api.v1.CreateMemoRequest + 6, // 32: memos.api.v1.MemoService.ListMemos:input_type -> memos.api.v1.ListMemosRequest + 8, // 33: memos.api.v1.MemoService.GetMemo:input_type -> memos.api.v1.GetMemoRequest + 9, // 34: memos.api.v1.MemoService.UpdateMemo:input_type -> memos.api.v1.UpdateMemoRequest + 10, // 35: memos.api.v1.MemoService.DeleteMemo:input_type -> memos.api.v1.DeleteMemoRequest + 11, // 36: memos.api.v1.MemoService.SetMemoAttachments:input_type -> memos.api.v1.SetMemoAttachmentsRequest + 12, // 37: memos.api.v1.MemoService.ListMemoAttachments:input_type -> memos.api.v1.ListMemoAttachmentsRequest + 15, // 38: memos.api.v1.MemoService.SetMemoRelations:input_type -> memos.api.v1.SetMemoRelationsRequest + 16, // 39: memos.api.v1.MemoService.ListMemoRelations:input_type -> memos.api.v1.ListMemoRelationsRequest + 18, // 40: memos.api.v1.MemoService.CreateMemoComment:input_type -> memos.api.v1.CreateMemoCommentRequest + 19, // 41: memos.api.v1.MemoService.ListMemoComments:input_type -> memos.api.v1.ListMemoCommentsRequest + 21, // 42: memos.api.v1.MemoService.ListMemoReactions:input_type -> memos.api.v1.ListMemoReactionsRequest + 23, // 43: memos.api.v1.MemoService.UpsertMemoReaction:input_type -> memos.api.v1.UpsertMemoReactionRequest + 24, // 44: memos.api.v1.MemoService.DeleteMemoReaction:input_type -> memos.api.v1.DeleteMemoReactionRequest + 26, // 45: memos.api.v1.MemoService.CreateMemoShare:input_type -> memos.api.v1.CreateMemoShareRequest + 27, // 46: memos.api.v1.MemoService.ListMemoShares:input_type -> memos.api.v1.ListMemoSharesRequest + 29, // 47: memos.api.v1.MemoService.DeleteMemoShare:input_type -> memos.api.v1.DeleteMemoShareRequest + 30, // 48: memos.api.v1.MemoService.GetMemoByShare:input_type -> memos.api.v1.GetMemoByShareRequest + 3, // 49: memos.api.v1.MemoService.CreateMemo:output_type -> memos.api.v1.Memo + 7, // 50: memos.api.v1.MemoService.ListMemos:output_type -> memos.api.v1.ListMemosResponse + 3, // 51: memos.api.v1.MemoService.GetMemo:output_type -> memos.api.v1.Memo + 3, // 52: memos.api.v1.MemoService.UpdateMemo:output_type -> memos.api.v1.Memo + 37, // 53: memos.api.v1.MemoService.DeleteMemo:output_type -> google.protobuf.Empty + 37, // 54: memos.api.v1.MemoService.SetMemoAttachments:output_type -> google.protobuf.Empty + 13, // 55: memos.api.v1.MemoService.ListMemoAttachments:output_type -> memos.api.v1.ListMemoAttachmentsResponse + 37, // 56: memos.api.v1.MemoService.SetMemoRelations:output_type -> google.protobuf.Empty + 17, // 57: memos.api.v1.MemoService.ListMemoRelations:output_type -> memos.api.v1.ListMemoRelationsResponse + 3, // 58: memos.api.v1.MemoService.CreateMemoComment:output_type -> memos.api.v1.Memo + 20, // 59: memos.api.v1.MemoService.ListMemoComments:output_type -> memos.api.v1.ListMemoCommentsResponse + 22, // 60: memos.api.v1.MemoService.ListMemoReactions:output_type -> memos.api.v1.ListMemoReactionsResponse + 2, // 61: memos.api.v1.MemoService.UpsertMemoReaction:output_type -> memos.api.v1.Reaction + 37, // 62: memos.api.v1.MemoService.DeleteMemoReaction:output_type -> google.protobuf.Empty + 25, // 63: memos.api.v1.MemoService.CreateMemoShare:output_type -> memos.api.v1.MemoShare + 28, // 64: memos.api.v1.MemoService.ListMemoShares:output_type -> memos.api.v1.ListMemoSharesResponse + 37, // 65: memos.api.v1.MemoService.DeleteMemoShare:output_type -> google.protobuf.Empty + 3, // 66: memos.api.v1.MemoService.GetMemoByShare:output_type -> memos.api.v1.Memo + 49, // [49:67] is the sub-list for method output_type + 31, // [31:49] is the sub-list for method input_type + 31, // [31:31] is the sub-list for extension type_name + 31, // [31:31] is the sub-list for extension extendee + 0, // [0:31] is the sub-list for field type_name } func init() { file_api_v1_memo_service_proto_init() } @@ -2105,13 +2455,14 @@ func file_api_v1_memo_service_proto_init() { file_api_v1_attachment_service_proto_init() file_api_v1_common_proto_init() file_api_v1_memo_service_proto_msgTypes[1].OneofWrappers = []any{} + file_api_v1_memo_service_proto_msgTypes[23].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_v1_memo_service_proto_rawDesc), len(file_api_v1_memo_service_proto_rawDesc)), NumEnums: 2, - NumMessages: 25, + NumMessages: 31, NumExtensions: 0, NumServices: 1, }, diff --git a/proto/gen/api/v1/memo_service.pb.gw.go b/proto/gen/api/v1/memo_service.pb.gw.go index 20d41a039..ed0fd6966 100644 --- a/proto/gen/api/v1/memo_service.pb.gw.go +++ b/proto/gen/api/v1/memo_service.pb.gw.go @@ -729,6 +729,168 @@ func local_request_MemoService_DeleteMemoReaction_0(ctx context.Context, marshal return msg, metadata, err } +func request_MemoService_CreateMemoShare_0(ctx context.Context, marshaler runtime.Marshaler, client MemoServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq CreateMemoShareRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq.MemoShare); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["parent"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "parent") + } + protoReq.Parent, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "parent", err) + } + msg, err := client.CreateMemoShare(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_MemoService_CreateMemoShare_0(ctx context.Context, marshaler runtime.Marshaler, server MemoServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq CreateMemoShareRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq.MemoShare); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + val, ok := pathParams["parent"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "parent") + } + protoReq.Parent, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "parent", err) + } + msg, err := server.CreateMemoShare(ctx, &protoReq) + return msg, metadata, err +} + +func request_MemoService_ListMemoShares_0(ctx context.Context, marshaler runtime.Marshaler, client MemoServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListMemoSharesRequest + metadata runtime.ServerMetadata + err error + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["parent"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "parent") + } + protoReq.Parent, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "parent", err) + } + msg, err := client.ListMemoShares(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_MemoService_ListMemoShares_0(ctx context.Context, marshaler runtime.Marshaler, server MemoServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq ListMemoSharesRequest + metadata runtime.ServerMetadata + err error + ) + val, ok := pathParams["parent"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "parent") + } + protoReq.Parent, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "parent", err) + } + msg, err := server.ListMemoShares(ctx, &protoReq) + return msg, metadata, err +} + +func request_MemoService_DeleteMemoShare_0(ctx context.Context, marshaler runtime.Marshaler, client MemoServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DeleteMemoShareRequest + metadata runtime.ServerMetadata + err error + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["name"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "name") + } + protoReq.Name, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "name", err) + } + msg, err := client.DeleteMemoShare(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_MemoService_DeleteMemoShare_0(ctx context.Context, marshaler runtime.Marshaler, server MemoServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq DeleteMemoShareRequest + metadata runtime.ServerMetadata + err error + ) + val, ok := pathParams["name"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "name") + } + protoReq.Name, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "name", err) + } + msg, err := server.DeleteMemoShare(ctx, &protoReq) + return msg, metadata, err +} + +func request_MemoService_GetMemoByShare_0(ctx context.Context, marshaler runtime.Marshaler, client MemoServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetMemoByShareRequest + metadata runtime.ServerMetadata + err error + ) + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["share_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "share_id") + } + protoReq.ShareId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "share_id", err) + } + msg, err := client.GetMemoByShare(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_MemoService_GetMemoByShare_0(ctx context.Context, marshaler runtime.Marshaler, server MemoServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq GetMemoByShareRequest + metadata runtime.ServerMetadata + err error + ) + val, ok := pathParams["share_id"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "share_id") + } + protoReq.ShareId, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "share_id", err) + } + msg, err := server.GetMemoByShare(ctx, &protoReq) + return msg, metadata, err +} + // RegisterMemoServiceHandlerServer registers the http handlers for service MemoService to "mux". // UnaryRPC :call MemoServiceServer directly. // StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. @@ -1015,6 +1177,86 @@ func RegisterMemoServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux } forward_MemoService_DeleteMemoReaction_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodPost, pattern_MemoService_CreateMemoShare_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/memos.api.v1.MemoService/CreateMemoShare", runtime.WithHTTPPathPattern("/api/v1/{parent=memos/*}/shares")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_MemoService_CreateMemoShare_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_MemoService_CreateMemoShare_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_MemoService_ListMemoShares_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/memos.api.v1.MemoService/ListMemoShares", runtime.WithHTTPPathPattern("/api/v1/{parent=memos/*}/shares")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_MemoService_ListMemoShares_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_MemoService_ListMemoShares_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodDelete, pattern_MemoService_DeleteMemoShare_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/memos.api.v1.MemoService/DeleteMemoShare", runtime.WithHTTPPathPattern("/api/v1/{name=memos/*/shares/*}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_MemoService_DeleteMemoShare_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_MemoService_DeleteMemoShare_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_MemoService_GetMemoByShare_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/memos.api.v1.MemoService/GetMemoByShare", runtime.WithHTTPPathPattern("/api/v1/shares/{share_id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_MemoService_GetMemoByShare_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_MemoService_GetMemoByShare_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } @@ -1293,6 +1535,74 @@ func RegisterMemoServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux } forward_MemoService_DeleteMemoReaction_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodPost, pattern_MemoService_CreateMemoShare_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/memos.api.v1.MemoService/CreateMemoShare", runtime.WithHTTPPathPattern("/api/v1/{parent=memos/*}/shares")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_MemoService_CreateMemoShare_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_MemoService_CreateMemoShare_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_MemoService_ListMemoShares_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/memos.api.v1.MemoService/ListMemoShares", runtime.WithHTTPPathPattern("/api/v1/{parent=memos/*}/shares")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_MemoService_ListMemoShares_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_MemoService_ListMemoShares_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodDelete, pattern_MemoService_DeleteMemoShare_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/memos.api.v1.MemoService/DeleteMemoShare", runtime.WithHTTPPathPattern("/api/v1/{name=memos/*/shares/*}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_MemoService_DeleteMemoShare_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_MemoService_DeleteMemoShare_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) + mux.Handle(http.MethodGet, pattern_MemoService_GetMemoByShare_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/memos.api.v1.MemoService/GetMemoByShare", runtime.WithHTTPPathPattern("/api/v1/shares/{share_id}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_MemoService_GetMemoByShare_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_MemoService_GetMemoByShare_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } @@ -1311,6 +1621,10 @@ var ( pattern_MemoService_ListMemoReactions_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3, 2, 4}, []string{"api", "v1", "memos", "name", "reactions"}, "")) pattern_MemoService_UpsertMemoReaction_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3, 2, 4}, []string{"api", "v1", "memos", "name", "reactions"}, "")) pattern_MemoService_DeleteMemoReaction_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 2, 3, 1, 0, 4, 4, 5, 4}, []string{"api", "v1", "memos", "reactions", "name"}, "")) + pattern_MemoService_CreateMemoShare_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3, 2, 4}, []string{"api", "v1", "memos", "parent", "shares"}, "")) + pattern_MemoService_ListMemoShares_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3, 2, 4}, []string{"api", "v1", "memos", "parent", "shares"}, "")) + pattern_MemoService_DeleteMemoShare_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 2, 3, 1, 0, 4, 4, 5, 4}, []string{"api", "v1", "memos", "shares", "name"}, "")) + pattern_MemoService_GetMemoByShare_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3}, []string{"api", "v1", "shares", "share_id"}, "")) ) var ( @@ -1328,4 +1642,8 @@ var ( forward_MemoService_ListMemoReactions_0 = runtime.ForwardResponseMessage forward_MemoService_UpsertMemoReaction_0 = runtime.ForwardResponseMessage forward_MemoService_DeleteMemoReaction_0 = runtime.ForwardResponseMessage + forward_MemoService_CreateMemoShare_0 = runtime.ForwardResponseMessage + forward_MemoService_ListMemoShares_0 = runtime.ForwardResponseMessage + forward_MemoService_DeleteMemoShare_0 = runtime.ForwardResponseMessage + forward_MemoService_GetMemoByShare_0 = runtime.ForwardResponseMessage ) diff --git a/proto/gen/api/v1/memo_service_grpc.pb.go b/proto/gen/api/v1/memo_service_grpc.pb.go index bcb9adfe4..79a07d28b 100644 --- a/proto/gen/api/v1/memo_service_grpc.pb.go +++ b/proto/gen/api/v1/memo_service_grpc.pb.go @@ -34,6 +34,10 @@ const ( MemoService_ListMemoReactions_FullMethodName = "/memos.api.v1.MemoService/ListMemoReactions" MemoService_UpsertMemoReaction_FullMethodName = "/memos.api.v1.MemoService/UpsertMemoReaction" MemoService_DeleteMemoReaction_FullMethodName = "/memos.api.v1.MemoService/DeleteMemoReaction" + MemoService_CreateMemoShare_FullMethodName = "/memos.api.v1.MemoService/CreateMemoShare" + MemoService_ListMemoShares_FullMethodName = "/memos.api.v1.MemoService/ListMemoShares" + MemoService_DeleteMemoShare_FullMethodName = "/memos.api.v1.MemoService/DeleteMemoShare" + MemoService_GetMemoByShare_FullMethodName = "/memos.api.v1.MemoService/GetMemoByShare" ) // MemoServiceClient is the client API for MemoService service. @@ -68,6 +72,15 @@ type MemoServiceClient interface { UpsertMemoReaction(ctx context.Context, in *UpsertMemoReactionRequest, opts ...grpc.CallOption) (*Reaction, error) // DeleteMemoReaction deletes a reaction for a memo. DeleteMemoReaction(ctx context.Context, in *DeleteMemoReactionRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + // CreateMemoShare creates a share link for a memo. Requires authentication as the memo creator. + CreateMemoShare(ctx context.Context, in *CreateMemoShareRequest, opts ...grpc.CallOption) (*MemoShare, error) + // ListMemoShares lists all share links for a memo. Requires authentication as the memo creator. + ListMemoShares(ctx context.Context, in *ListMemoSharesRequest, opts ...grpc.CallOption) (*ListMemoSharesResponse, error) + // DeleteMemoShare revokes a share link. Requires authentication as the memo creator. + DeleteMemoShare(ctx context.Context, in *DeleteMemoShareRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + // GetMemoByShare resolves a share token to its memo. No authentication required. + // Returns NOT_FOUND if the token is invalid or expired. + GetMemoByShare(ctx context.Context, in *GetMemoByShareRequest, opts ...grpc.CallOption) (*Memo, error) } type memoServiceClient struct { @@ -218,6 +231,46 @@ func (c *memoServiceClient) DeleteMemoReaction(ctx context.Context, in *DeleteMe return out, nil } +func (c *memoServiceClient) CreateMemoShare(ctx context.Context, in *CreateMemoShareRequest, opts ...grpc.CallOption) (*MemoShare, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(MemoShare) + err := c.cc.Invoke(ctx, MemoService_CreateMemoShare_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *memoServiceClient) ListMemoShares(ctx context.Context, in *ListMemoSharesRequest, opts ...grpc.CallOption) (*ListMemoSharesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListMemoSharesResponse) + err := c.cc.Invoke(ctx, MemoService_ListMemoShares_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *memoServiceClient) DeleteMemoShare(ctx context.Context, in *DeleteMemoShareRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, MemoService_DeleteMemoShare_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *memoServiceClient) GetMemoByShare(ctx context.Context, in *GetMemoByShareRequest, opts ...grpc.CallOption) (*Memo, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Memo) + err := c.cc.Invoke(ctx, MemoService_GetMemoByShare_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // MemoServiceServer is the server API for MemoService service. // All implementations must embed UnimplementedMemoServiceServer // for forward compatibility. @@ -250,6 +303,15 @@ type MemoServiceServer interface { UpsertMemoReaction(context.Context, *UpsertMemoReactionRequest) (*Reaction, error) // DeleteMemoReaction deletes a reaction for a memo. DeleteMemoReaction(context.Context, *DeleteMemoReactionRequest) (*emptypb.Empty, error) + // CreateMemoShare creates a share link for a memo. Requires authentication as the memo creator. + CreateMemoShare(context.Context, *CreateMemoShareRequest) (*MemoShare, error) + // ListMemoShares lists all share links for a memo. Requires authentication as the memo creator. + ListMemoShares(context.Context, *ListMemoSharesRequest) (*ListMemoSharesResponse, error) + // DeleteMemoShare revokes a share link. Requires authentication as the memo creator. + DeleteMemoShare(context.Context, *DeleteMemoShareRequest) (*emptypb.Empty, error) + // GetMemoByShare resolves a share token to its memo. No authentication required. + // Returns NOT_FOUND if the token is invalid or expired. + GetMemoByShare(context.Context, *GetMemoByShareRequest) (*Memo, error) mustEmbedUnimplementedMemoServiceServer() } @@ -302,6 +364,18 @@ func (UnimplementedMemoServiceServer) UpsertMemoReaction(context.Context, *Upser func (UnimplementedMemoServiceServer) DeleteMemoReaction(context.Context, *DeleteMemoReactionRequest) (*emptypb.Empty, error) { return nil, status.Error(codes.Unimplemented, "method DeleteMemoReaction not implemented") } +func (UnimplementedMemoServiceServer) CreateMemoShare(context.Context, *CreateMemoShareRequest) (*MemoShare, error) { + return nil, status.Error(codes.Unimplemented, "method CreateMemoShare not implemented") +} +func (UnimplementedMemoServiceServer) ListMemoShares(context.Context, *ListMemoSharesRequest) (*ListMemoSharesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListMemoShares not implemented") +} +func (UnimplementedMemoServiceServer) DeleteMemoShare(context.Context, *DeleteMemoShareRequest) (*emptypb.Empty, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteMemoShare not implemented") +} +func (UnimplementedMemoServiceServer) GetMemoByShare(context.Context, *GetMemoByShareRequest) (*Memo, error) { + return nil, status.Error(codes.Unimplemented, "method GetMemoByShare not implemented") +} func (UnimplementedMemoServiceServer) mustEmbedUnimplementedMemoServiceServer() {} func (UnimplementedMemoServiceServer) testEmbeddedByValue() {} @@ -575,6 +649,78 @@ func _MemoService_DeleteMemoReaction_Handler(srv interface{}, ctx context.Contex return interceptor(ctx, in, info, handler) } +func _MemoService_CreateMemoShare_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateMemoShareRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(MemoServiceServer).CreateMemoShare(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: MemoService_CreateMemoShare_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(MemoServiceServer).CreateMemoShare(ctx, req.(*CreateMemoShareRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _MemoService_ListMemoShares_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListMemoSharesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(MemoServiceServer).ListMemoShares(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: MemoService_ListMemoShares_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(MemoServiceServer).ListMemoShares(ctx, req.(*ListMemoSharesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _MemoService_DeleteMemoShare_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteMemoShareRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(MemoServiceServer).DeleteMemoShare(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: MemoService_DeleteMemoShare_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(MemoServiceServer).DeleteMemoShare(ctx, req.(*DeleteMemoShareRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _MemoService_GetMemoByShare_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetMemoByShareRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(MemoServiceServer).GetMemoByShare(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: MemoService_GetMemoByShare_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(MemoServiceServer).GetMemoByShare(ctx, req.(*GetMemoByShareRequest)) + } + return interceptor(ctx, in, info, handler) +} + // MemoService_ServiceDesc is the grpc.ServiceDesc for MemoService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -638,6 +784,22 @@ var MemoService_ServiceDesc = grpc.ServiceDesc{ MethodName: "DeleteMemoReaction", Handler: _MemoService_DeleteMemoReaction_Handler, }, + { + MethodName: "CreateMemoShare", + Handler: _MemoService_CreateMemoShare_Handler, + }, + { + MethodName: "ListMemoShares", + Handler: _MemoService_ListMemoShares_Handler, + }, + { + MethodName: "DeleteMemoShare", + Handler: _MemoService_DeleteMemoShare_Handler, + }, + { + MethodName: "GetMemoByShare", + Handler: _MemoService_GetMemoByShare_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "api/v1/memo_service.proto", diff --git a/proto/gen/openapi.yaml b/proto/gen/openapi.yaml index 7983a4097..fc8be07b3 100644 --- a/proto/gen/openapi.yaml +++ b/proto/gen/openapi.yaml @@ -991,6 +991,120 @@ paths: application/json: schema: $ref: '#/components/schemas/Status' + /api/v1/memos/{memo}/shares: + get: + tags: + - MemoService + description: ListMemoShares lists all share links for a memo. Requires authentication as the memo creator. + operationId: MemoService_ListMemoShares + parameters: + - name: memo + in: path + description: The memo id. + required: true + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ListMemoSharesResponse' + default: + description: Default error response + content: + application/json: + schema: + $ref: '#/components/schemas/Status' + post: + tags: + - MemoService + description: CreateMemoShare creates a share link for a memo. Requires authentication as the memo creator. + operationId: MemoService_CreateMemoShare + parameters: + - name: memo + in: path + description: The memo id. + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MemoShare' + required: true + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MemoShare' + default: + description: Default error response + content: + application/json: + schema: + $ref: '#/components/schemas/Status' + /api/v1/memos/{memo}/shares/{share}: + delete: + tags: + - MemoService + description: DeleteMemoShare revokes a share link. Requires authentication as the memo creator. + operationId: MemoService_DeleteMemoShare + parameters: + - name: memo + in: path + description: The memo id. + required: true + schema: + type: string + - name: share + in: path + description: The share id. + required: true + schema: + type: string + responses: + "200": + description: OK + content: {} + default: + description: Default error response + content: + application/json: + schema: + $ref: '#/components/schemas/Status' + /api/v1/shares/{shareId}: + get: + tags: + - MemoService + description: |- + GetMemoByShare resolves a share token to its memo. No authentication required. + Returns NOT_FOUND if the token is invalid or expired. + operationId: MemoService_GetMemoByShare + parameters: + - name: shareId + in: path + description: Required. The share token extracted from the share URL (/s/{share_id}). + required: true + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Memo' + default: + description: Default error response + content: + application/json: + schema: + $ref: '#/components/schemas/Status' /api/v1/users: get: tags: @@ -2382,6 +2496,14 @@ components: nextPageToken: type: string description: A token for the next page of results. + ListMemoSharesResponse: + type: object + properties: + memoShares: + type: array + items: + $ref: '#/components/schemas/MemoShare' + description: The list of share links. ListMemosResponse: type: object properties: @@ -2619,6 +2741,26 @@ components: type: string description: Output only. The snippet of the memo content. Plain text only. description: Memo reference in relations. + MemoShare: + type: object + properties: + name: + type: string + description: |- + The resource name of the share. Format: memos/{memo}/shares/{share} + The {share} segment is the opaque token used in the share URL. + createTime: + readOnly: true + type: string + description: Output only. When this share link was created. + format: date-time + expireTime: + type: string + description: |- + Optional. When set, the share link stops working after this time. + If unset, the link never expires. + format: date-time + description: MemoShare is an access grant that permits read-only access to a memo via an opaque bearer token. Memo_Property: type: object properties: diff --git a/server/router/api/v1/acl_config.go b/server/router/api/v1/acl_config.go index 9958900b2..eca0086ba 100644 --- a/server/router/api/v1/acl_config.go +++ b/server/router/api/v1/acl_config.go @@ -32,6 +32,9 @@ var PublicMethods = map[string]struct{}{ "/memos.api.v1.MemoService/GetMemo": {}, "/memos.api.v1.MemoService/ListMemos": {}, "/memos.api.v1.MemoService/ListMemoComments": {}, + + // Memo sharing - share-token endpoints require no authentication + "/memos.api.v1.MemoService/GetMemoByShare": {}, } // IsPublicMethod checks if a procedure path is public (no authentication required). diff --git a/server/router/api/v1/connect_services.go b/server/router/api/v1/connect_services.go index ecc7f625f..9b19bfd7f 100644 --- a/server/router/api/v1/connect_services.go +++ b/server/router/api/v1/connect_services.go @@ -345,6 +345,38 @@ func (s *ConnectServiceHandler) DeleteMemoReaction(ctx context.Context, req *con return connect.NewResponse(resp), nil } +func (s *ConnectServiceHandler) CreateMemoShare(ctx context.Context, req *connect.Request[v1pb.CreateMemoShareRequest]) (*connect.Response[v1pb.MemoShare], error) { + resp, err := s.APIV1Service.CreateMemoShare(ctx, req.Msg) + if err != nil { + return nil, convertGRPCError(err) + } + return connect.NewResponse(resp), nil +} + +func (s *ConnectServiceHandler) ListMemoShares(ctx context.Context, req *connect.Request[v1pb.ListMemoSharesRequest]) (*connect.Response[v1pb.ListMemoSharesResponse], error) { + resp, err := s.APIV1Service.ListMemoShares(ctx, req.Msg) + if err != nil { + return nil, convertGRPCError(err) + } + return connect.NewResponse(resp), nil +} + +func (s *ConnectServiceHandler) DeleteMemoShare(ctx context.Context, req *connect.Request[v1pb.DeleteMemoShareRequest]) (*connect.Response[emptypb.Empty], error) { + resp, err := s.APIV1Service.DeleteMemoShare(ctx, req.Msg) + if err != nil { + return nil, convertGRPCError(err) + } + return connect.NewResponse(resp), nil +} + +func (s *ConnectServiceHandler) GetMemoByShare(ctx context.Context, req *connect.Request[v1pb.GetMemoByShareRequest]) (*connect.Response[v1pb.Memo], error) { + resp, err := s.APIV1Service.GetMemoByShare(ctx, req.Msg) + if err != nil { + return nil, convertGRPCError(err) + } + return connect.NewResponse(resp), nil +} + // AttachmentService func (s *ConnectServiceHandler) CreateAttachment(ctx context.Context, req *connect.Request[v1pb.CreateAttachmentRequest]) (*connect.Response[v1pb.Attachment], error) { diff --git a/server/router/api/v1/memo_share_service.go b/server/router/api/v1/memo_share_service.go new file mode 100644 index 000000000..e71fee635 --- /dev/null +++ b/server/router/api/v1/memo_share_service.go @@ -0,0 +1,222 @@ +package v1 + +import ( + "context" + "fmt" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/emptypb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/lithammer/shortuuid/v4" + "github.com/pkg/errors" + + v1pb "github.com/usememos/memos/proto/gen/api/v1" + "github.com/usememos/memos/store" +) + +// CreateMemoShare creates an opaque share link for a memo. +// Only the memo's creator or an admin may call this. +func (s *APIV1Service) CreateMemoShare(ctx context.Context, request *v1pb.CreateMemoShareRequest) (*v1pb.MemoShare, error) { + user, err := s.fetchCurrentUser(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get user") + } + if user == nil { + return nil, status.Errorf(codes.Unauthenticated, "user not authenticated") + } + + memoUID, err := ExtractMemoUIDFromName(request.Parent) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err) + } + memo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID}) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get memo") + } + if memo == nil { + return nil, status.Errorf(codes.NotFound, "memo not found") + } + if memo.CreatorID != user.ID && !isSuperUser(user) { + return nil, status.Errorf(codes.PermissionDenied, "permission denied") + } + + var expiresTs *int64 + if request.MemoShare != nil && request.MemoShare.ExpireTime != nil { + ts := request.MemoShare.ExpireTime.AsTime().Unix() + if ts <= time.Now().Unix() { + return nil, status.Errorf(codes.InvalidArgument, "expire_time must be in the future") + } + expiresTs = &ts + } + + // Generate a URL-safe token using shortuuid (base57-encoded UUID v4, 22 chars, 122-bit entropy). + ms, err := s.Store.CreateMemoShare(ctx, &store.MemoShare{ + UID: shortuuid.New(), + MemoID: memo.ID, + CreatorID: user.ID, + ExpiresTs: expiresTs, + }) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to create memo share") + } + + return convertMemoShareFromStore(ms, memo.UID), nil +} + +// ListMemoShares lists all share links for a memo. +// Only the memo's creator or an admin may call this. +func (s *APIV1Service) ListMemoShares(ctx context.Context, request *v1pb.ListMemoSharesRequest) (*v1pb.ListMemoSharesResponse, error) { + user, err := s.fetchCurrentUser(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get user") + } + if user == nil { + return nil, status.Errorf(codes.Unauthenticated, "user not authenticated") + } + + memoUID, err := ExtractMemoUIDFromName(request.Parent) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err) + } + memo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID}) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get memo") + } + if memo == nil { + return nil, status.Errorf(codes.NotFound, "memo not found") + } + if memo.CreatorID != user.ID && !isSuperUser(user) { + return nil, status.Errorf(codes.PermissionDenied, "permission denied") + } + + shares, err := s.Store.ListMemoShares(ctx, &store.FindMemoShare{MemoID: &memo.ID}) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to list memo shares") + } + + response := &v1pb.ListMemoSharesResponse{} + for _, ms := range shares { + response.MemoShares = append(response.MemoShares, convertMemoShareFromStore(ms, memo.UID)) + } + return response, nil +} + +// DeleteMemoShare revokes a share link. +// Only the memo's creator or an admin may call this. +func (s *APIV1Service) DeleteMemoShare(ctx context.Context, request *v1pb.DeleteMemoShareRequest) (*emptypb.Empty, error) { + user, err := s.fetchCurrentUser(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get user") + } + if user == nil { + return nil, status.Errorf(codes.Unauthenticated, "user not authenticated") + } + + // name format: memos/{memoUID}/shares/{shareToken} + tokens, err := GetNameParentTokens(request.Name, MemoNamePrefix, MemoShareNamePrefix) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid share name: %v", err) + } + memoUID, shareToken := tokens[0], tokens[1] + + memo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID}) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get memo") + } + if memo == nil { + return nil, status.Errorf(codes.NotFound, "memo not found") + } + if memo.CreatorID != user.ID && !isSuperUser(user) { + return nil, status.Errorf(codes.PermissionDenied, "permission denied") + } + + ms, err := s.Store.GetMemoShare(ctx, &store.FindMemoShare{UID: &shareToken}) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get memo share") + } + if ms == nil || ms.MemoID != memo.ID { + return nil, status.Errorf(codes.NotFound, "memo share not found") + } + + if err := s.Store.DeleteMemoShare(ctx, &store.DeleteMemoShare{UID: &shareToken}); err != nil { + return nil, status.Errorf(codes.Internal, "failed to delete memo share") + } + return &emptypb.Empty{}, nil +} + +// GetMemoByShare resolves a share token to its memo. No authentication required. +// Returns NOT_FOUND for invalid or expired tokens (no information leakage). +func (s *APIV1Service) GetMemoByShare(ctx context.Context, request *v1pb.GetMemoByShareRequest) (*v1pb.Memo, error) { + ms, err := s.getActiveMemoShare(ctx, request.ShareId) + if err != nil { + return nil, err + } + + memo, err := s.Store.GetMemo(ctx, &store.FindMemo{ID: &ms.MemoID}) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get memo") + } + // Treat archived or missing memos the same as an invalid token — no information leakage. + if memo == nil || memo.RowStatus == store.Archived { + return nil, status.Errorf(codes.NotFound, "not found") + } + + reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{ + ContentID: stringPointer(fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID)), + }) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to list reactions") + } + + attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{MemoID: &memo.ID}) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to list attachments") + } + relations, err := s.batchConvertMemoRelations(ctx, []*store.Memo{memo}) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to load memo relations") + } + + memoMessage, err := s.convertMemoFromStore(ctx, memo, reactions, attachments, relations[memo.ID]) + if err != nil { + return nil, errors.Wrap(err, "failed to convert memo") + } + return memoMessage, nil +} + +// isMemoShareExpired returns true if the share has a defined expiry that has already passed. +func isMemoShareExpired(ms *store.MemoShare) bool { + return ms.ExpiresTs != nil && time.Now().Unix() > *ms.ExpiresTs +} + +func (s *APIV1Service) getActiveMemoShare(ctx context.Context, shareID string) (*store.MemoShare, error) { + ms, err := s.Store.GetMemoShare(ctx, &store.FindMemoShare{UID: &shareID}) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get memo share") + } + if ms == nil || isMemoShareExpired(ms) { + return nil, status.Errorf(codes.NotFound, "not found") + } + return ms, nil +} + +func stringPointer(s string) *string { + return &s +} + +// convertMemoShareFromStore converts a store MemoShare to the proto MemoShare message. +// name format: memos/{memoUID}/shares/{shareToken}. +func convertMemoShareFromStore(ms *store.MemoShare, memoUID string) *v1pb.MemoShare { + name := fmt.Sprintf("%s%s/%s%s", MemoNamePrefix, memoUID, MemoShareNamePrefix, ms.UID) + pb := &v1pb.MemoShare{ + Name: name, + CreateTime: timestamppb.New(time.Unix(ms.CreatedTs, 0)), + } + if ms.ExpiresTs != nil { + pb.ExpireTime = timestamppb.New(time.Unix(*ms.ExpiresTs, 0)) + } + return pb +} diff --git a/server/router/api/v1/resource_name.go b/server/router/api/v1/resource_name.go index f7b74d42a..141b6748d 100644 --- a/server/router/api/v1/resource_name.go +++ b/server/router/api/v1/resource_name.go @@ -17,6 +17,7 @@ const ( InstanceSettingNamePrefix = "instance/settings/" UserNamePrefix = "users/" MemoNamePrefix = "memos/" + MemoShareNamePrefix = "shares/" AttachmentNamePrefix = "attachments/" ReactionNamePrefix = "reactions/" InboxNamePrefix = "inboxes/" diff --git a/server/router/api/v1/test/memo_share_service_test.go b/server/router/api/v1/test/memo_share_service_test.go new file mode 100644 index 000000000..110b83a35 --- /dev/null +++ b/server/router/api/v1/test/memo_share_service_test.go @@ -0,0 +1,109 @@ +package test + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + apiv1 "github.com/usememos/memos/proto/gen/api/v1" +) + +func TestDeleteMemoShare_VerifiesShareBelongsToMemo(t *testing.T) { + ctx := context.Background() + + ts := NewTestService(t) + defer ts.Cleanup() + + userOne, err := ts.CreateRegularUser(ctx, "share-owner-one") + require.NoError(t, err) + userTwo, err := ts.CreateRegularUser(ctx, "share-owner-two") + require.NoError(t, err) + + userOneCtx := ts.CreateUserContext(ctx, userOne.ID) + userTwoCtx := ts.CreateUserContext(ctx, userTwo.ID) + + memoOne, err := ts.Service.CreateMemo(userOneCtx, &apiv1.CreateMemoRequest{ + Memo: &apiv1.Memo{ + Content: "memo one", + Visibility: apiv1.Visibility_PRIVATE, + }, + }) + require.NoError(t, err) + + memoTwo, err := ts.Service.CreateMemo(userTwoCtx, &apiv1.CreateMemoRequest{ + Memo: &apiv1.Memo{ + Content: "memo two", + Visibility: apiv1.Visibility_PRIVATE, + }, + }) + require.NoError(t, err) + + share, err := ts.Service.CreateMemoShare(userTwoCtx, &apiv1.CreateMemoShareRequest{ + Parent: memoTwo.Name, + MemoShare: &apiv1.MemoShare{}, + }) + require.NoError(t, err) + + shareToken := share.Name[strings.LastIndex(share.Name, "/")+1:] + forgedName := memoOne.Name + "/shares/" + shareToken + + _, err = ts.Service.DeleteMemoShare(userOneCtx, &apiv1.DeleteMemoShareRequest{ + Name: forgedName, + }) + require.Error(t, err) + require.Equal(t, codes.NotFound, status.Code(err)) + + sharedMemo, err := ts.Service.GetMemoByShare(ctx, &apiv1.GetMemoByShareRequest{ + ShareId: shareToken, + }) + require.NoError(t, err) + require.Equal(t, memoTwo.Name, sharedMemo.Name) +} + +func TestGetMemoByShare_IncludesReactions(t *testing.T) { + ctx := context.Background() + + ts := NewTestService(t) + defer ts.Cleanup() + + user, err := ts.CreateRegularUser(ctx, "share-reactions") + require.NoError(t, err) + userCtx := ts.CreateUserContext(ctx, user.ID) + + memo, err := ts.Service.CreateMemo(userCtx, &apiv1.CreateMemoRequest{ + Memo: &apiv1.Memo{ + Content: "memo with reactions", + Visibility: apiv1.Visibility_PRIVATE, + }, + }) + require.NoError(t, err) + + reaction, err := ts.Service.UpsertMemoReaction(userCtx, &apiv1.UpsertMemoReactionRequest{ + Name: memo.Name, + Reaction: &apiv1.Reaction{ + ContentId: memo.Name, + ReactionType: "👍", + }, + }) + require.NoError(t, err) + require.NotNil(t, reaction) + + share, err := ts.Service.CreateMemoShare(userCtx, &apiv1.CreateMemoShareRequest{ + Parent: memo.Name, + MemoShare: &apiv1.MemoShare{}, + }) + require.NoError(t, err) + + shareToken := share.Name[strings.LastIndex(share.Name, "/")+1:] + sharedMemo, err := ts.Service.GetMemoByShare(ctx, &apiv1.GetMemoByShareRequest{ + ShareId: shareToken, + }) + require.NoError(t, err) + require.Len(t, sharedMemo.Reactions, 1) + require.Equal(t, "👍", sharedMemo.Reactions[0].ReactionType) + require.Equal(t, memo.Name, sharedMemo.Reactions[0].ContentId) +} diff --git a/server/router/fileserver/fileserver.go b/server/router/fileserver/fileserver.go index 5fb86aabd..cf4f7c147 100644 --- a/server/router/fileserver/fileserver.go +++ b/server/router/fileserver/fileserver.go @@ -497,6 +497,16 @@ func (s *FileServerService) checkAttachmentPermission(ctx context.Context, c *ec return nil } + // Check share token fallback: allow access if request carries a valid, non-expired share token + // that was issued for this specific memo. This covers attachment requests made from the shared + // memo page for private or protected memos. + if shareToken := (*c).QueryParam("share_token"); shareToken != "" { + ms, err := s.Store.GetMemoShare(ctx, &store.FindMemoShare{UID: &shareToken}) + if err == nil && ms != nil && !isMemoShareExpired(ms) && ms.MemoID == memo.ID { + return nil + } + } + user, err := s.getCurrentUser(ctx, c) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "failed to get current user").Wrap(err) @@ -585,3 +595,8 @@ func setMediaHeaders(c *echo.Context, contentType, originalType string) { h.Set("Color-Gamut", "srgb, p3, rec2020") } } + +// isMemoShareExpired returns true if the share has a defined expiry that has already passed. +func isMemoShareExpired(ms *store.MemoShare) bool { + return ms.ExpiresTs != nil && time.Now().Unix() > *ms.ExpiresTs +} diff --git a/server/router/fileserver/fileserver_test.go b/server/router/fileserver/fileserver_test.go new file mode 100644 index 000000000..ab90a31ee --- /dev/null +++ b/server/router/fileserver/fileserver_test.go @@ -0,0 +1,168 @@ +package fileserver + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/require" + + "github.com/usememos/memos/internal/profile" + "github.com/usememos/memos/plugin/markdown" + apiv1 "github.com/usememos/memos/proto/gen/api/v1" + "github.com/usememos/memos/server/auth" + apiv1service "github.com/usememos/memos/server/router/api/v1" + "github.com/usememos/memos/store" + teststore "github.com/usememos/memos/store/test" +) + +func TestServeAttachmentFile_ShareTokenAllowsDirectMemoAttachment(t *testing.T) { + ctx := context.Background() + svc, fs, _, cleanup := newShareAttachmentTestServices(ctx, t) + defer cleanup() + + creator, err := svc.Store.CreateUser(ctx, &store.User{ + Username: "share-parent-owner", + Role: store.RoleUser, + Email: "share-parent-owner@example.com", + }) + require.NoError(t, err) + + creatorCtx := context.WithValue(ctx, auth.UserIDContextKey, creator.ID) + + attachment, err := svc.CreateAttachment(creatorCtx, &apiv1.CreateAttachmentRequest{ + Attachment: &apiv1.Attachment{ + Filename: "memo.txt", + Type: "text/plain", + Content: []byte("memo attachment"), + }, + }) + require.NoError(t, err) + + parentMemo, err := svc.CreateMemo(creatorCtx, &apiv1.CreateMemoRequest{ + Memo: &apiv1.Memo{ + Content: "shared parent", + Visibility: apiv1.Visibility_PROTECTED, + Attachments: []*apiv1.Attachment{ + {Name: attachment.Name}, + }, + }, + }) + require.NoError(t, err) + + share, err := svc.CreateMemoShare(creatorCtx, &apiv1.CreateMemoShareRequest{ + Parent: parentMemo.Name, + MemoShare: &apiv1.MemoShare{}, + }) + require.NoError(t, err) + shareToken := share.Name[strings.LastIndex(share.Name, "/")+1:] + + e := echo.New() + fs.RegisterRoutes(e) + + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/file/%s/%s?share_token=%s", attachment.Name, attachment.Filename, shareToken), nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "memo attachment", rec.Body.String()) +} + +func TestServeAttachmentFile_ShareTokenRejectsCommentAttachment(t *testing.T) { + ctx := context.Background() + svc, fs, _, cleanup := newShareAttachmentTestServices(ctx, t) + defer cleanup() + + creator, err := svc.Store.CreateUser(ctx, &store.User{ + Username: "private-parent-owner", + Role: store.RoleUser, + Email: "private-parent-owner@example.com", + }) + require.NoError(t, err) + + creatorCtx := context.WithValue(ctx, auth.UserIDContextKey, creator.ID) + commenter, err := svc.Store.CreateUser(ctx, &store.User{ + Username: "share-commenter", + Role: store.RoleUser, + Email: "share-commenter@example.com", + }) + require.NoError(t, err) + commenterCtx := context.WithValue(ctx, auth.UserIDContextKey, commenter.ID) + + parentMemo, err := svc.CreateMemo(creatorCtx, &apiv1.CreateMemoRequest{ + Memo: &apiv1.Memo{ + Content: "shared parent", + Visibility: apiv1.Visibility_PROTECTED, + }, + }) + require.NoError(t, err) + + commentAttachment, err := svc.CreateAttachment(commenterCtx, &apiv1.CreateAttachmentRequest{ + Attachment: &apiv1.Attachment{ + Filename: "comment.txt", + Type: "text/plain", + Content: []byte("comment attachment"), + }, + }) + require.NoError(t, err) + + _, err = svc.CreateMemoComment(commenterCtx, &apiv1.CreateMemoCommentRequest{ + Name: parentMemo.Name, + Comment: &apiv1.Memo{ + Content: "comment with attachment", + Visibility: apiv1.Visibility_PROTECTED, + Attachments: []*apiv1.Attachment{ + {Name: commentAttachment.Name}, + }, + }, + }) + require.NoError(t, err) + + share, err := svc.CreateMemoShare(creatorCtx, &apiv1.CreateMemoShareRequest{ + Parent: parentMemo.Name, + MemoShare: &apiv1.MemoShare{}, + }) + require.NoError(t, err) + shareToken := share.Name[strings.LastIndex(share.Name, "/")+1:] + + e := echo.New() + fs.RegisterRoutes(e) + + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/file/%s/%s?share_token=%s", commentAttachment.Name, commentAttachment.Filename, shareToken), nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + require.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func newShareAttachmentTestServices(ctx context.Context, t *testing.T) (*apiv1service.APIV1Service, *FileServerService, *store.Store, func()) { + t.Helper() + + testStore := teststore.NewTestingStore(ctx, t) + testProfile := &profile.Profile{ + Demo: true, + Version: "test-1.0.0", + InstanceURL: "http://localhost:8080", + Driver: "sqlite", + DSN: ":memory:", + Data: t.TempDir(), + } + secret := "test-secret" + markdownService := markdown.NewService(markdown.WithTagExtension()) + apiService := &apiv1service.APIV1Service{ + Secret: secret, + Profile: testProfile, + Store: testStore, + MarkdownService: markdownService, + SSEHub: apiv1service.NewSSEHub(), + } + fileService := NewFileServerService(testProfile, testStore, secret) + + return apiService, fileService, testStore, func() { + testStore.Close() + } +} diff --git a/store/db/mysql/memo_share.go b/store/db/mysql/memo_share.go new file mode 100644 index 000000000..0abf702bd --- /dev/null +++ b/store/db/mysql/memo_share.go @@ -0,0 +1,118 @@ +package mysql + +import ( + "context" + "strings" + + "github.com/pkg/errors" + + "github.com/usememos/memos/store" +) + +func (d *DB) CreateMemoShare(ctx context.Context, create *store.MemoShare) (*store.MemoShare, error) { + fields := []string{"`uid`", "`memo_id`", "`creator_id`"} + placeholders := []string{"?", "?", "?"} + args := []any{create.UID, create.MemoID, create.CreatorID} + + if create.ExpiresTs != nil { + fields = append(fields, "`expires_ts`") + placeholders = append(placeholders, "?") + args = append(args, *create.ExpiresTs) + } + + stmt := "INSERT INTO `memo_share` (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(placeholders, ", ") + ")" + result, err := d.db.ExecContext(ctx, stmt, args...) + if err != nil { + return nil, err + } + + rawID, err := result.LastInsertId() + if err != nil { + return nil, err + } + id := int32(rawID) + ms, err := d.GetMemoShare(ctx, &store.FindMemoShare{ID: &id}) + if err != nil { + return nil, err + } + if ms == nil { + return nil, errors.Errorf("failed to create memo share") + } + return ms, nil +} + +func (d *DB) ListMemoShares(ctx context.Context, find *store.FindMemoShare) ([]*store.MemoShare, error) { + where, args := []string{"1 = 1"}, []any{} + + if find.ID != nil { + where, args = append(where, "`id` = ?"), append(args, *find.ID) + } + if find.UID != nil { + where, args = append(where, "`uid` = ?"), append(args, *find.UID) + } + if find.MemoID != nil { + where, args = append(where, "`memo_id` = ?"), append(args, *find.MemoID) + } + + rows, err := d.db.QueryContext(ctx, ` + SELECT + id, + uid, + memo_id, + creator_id, + created_ts, + expires_ts + FROM memo_share + WHERE `+strings.Join(where, " AND ")+` + ORDER BY id ASC`, + args..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + list := []*store.MemoShare{} + for rows.Next() { + ms := &store.MemoShare{} + if err := rows.Scan( + &ms.ID, + &ms.UID, + &ms.MemoID, + &ms.CreatorID, + &ms.CreatedTs, + &ms.ExpiresTs, + ); err != nil { + return nil, err + } + list = append(list, ms) + } + + if err := rows.Err(); err != nil { + return nil, err + } + return list, nil +} + +func (d *DB) GetMemoShare(ctx context.Context, find *store.FindMemoShare) (*store.MemoShare, error) { + list, err := d.ListMemoShares(ctx, find) + if err != nil { + return nil, err + } + if len(list) == 0 { + return nil, nil + } + return list[0], nil +} + +func (d *DB) DeleteMemoShare(ctx context.Context, delete *store.DeleteMemoShare) error { + where, args := []string{"1 = 1"}, []any{} + if delete.ID != nil { + where, args = append(where, "`id` = ?"), append(args, *delete.ID) + } + if delete.UID != nil { + where, args = append(where, "`uid` = ?"), append(args, *delete.UID) + } + _, err := d.db.ExecContext(ctx, "DELETE FROM `memo_share` WHERE "+strings.Join(where, " AND "), args...) + return err +} diff --git a/store/db/postgres/memo_share.go b/store/db/postgres/memo_share.go new file mode 100644 index 000000000..77b5be985 --- /dev/null +++ b/store/db/postgres/memo_share.go @@ -0,0 +1,136 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "strings" + + "github.com/usememos/memos/store" +) + +func (d *DB) CreateMemoShare(ctx context.Context, create *store.MemoShare) (*store.MemoShare, error) { + fields := []string{"uid", "memo_id", "creator_id"} + args := []any{create.UID, create.MemoID, create.CreatorID} + + if create.ExpiresTs != nil { + fields = append(fields, "expires_ts") + args = append(args, *create.ExpiresTs) + } + + stmt := "INSERT INTO memo_share (" + strings.Join(fields, ", ") + ") VALUES (" + placeholders(len(args)) + ") RETURNING id, created_ts" + if err := d.db.QueryRowContext(ctx, stmt, args...).Scan( + &create.ID, + &create.CreatedTs, + ); err != nil { + return nil, err + } + return create, nil +} + +func (d *DB) ListMemoShares(ctx context.Context, find *store.FindMemoShare) ([]*store.MemoShare, error) { + where, args := []string{"1 = 1"}, []any{} + + if find.ID != nil { + where, args = append(where, "id = "+placeholder(len(args)+1)), append(args, *find.ID) + } + if find.UID != nil { + where, args = append(where, "uid = "+placeholder(len(args)+1)), append(args, *find.UID) + } + if find.MemoID != nil { + where, args = append(where, "memo_id = "+placeholder(len(args)+1)), append(args, *find.MemoID) + } + + rows, err := d.db.QueryContext(ctx, ` + SELECT + id, + uid, + memo_id, + creator_id, + created_ts, + expires_ts + FROM memo_share + WHERE `+strings.Join(where, " AND ")+` + ORDER BY id ASC`, + args..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + list := []*store.MemoShare{} + for rows.Next() { + ms := &store.MemoShare{} + if err := rows.Scan( + &ms.ID, + &ms.UID, + &ms.MemoID, + &ms.CreatorID, + &ms.CreatedTs, + &ms.ExpiresTs, + ); err != nil { + return nil, err + } + list = append(list, ms) + } + + if err := rows.Err(); err != nil { + return nil, err + } + return list, nil +} + +func (d *DB) GetMemoShare(ctx context.Context, find *store.FindMemoShare) (*store.MemoShare, error) { + where, args := []string{"1 = 1"}, []any{} + + if find.ID != nil { + where, args = append(where, "id = "+placeholder(len(args)+1)), append(args, *find.ID) + } + if find.UID != nil { + where, args = append(where, "uid = "+placeholder(len(args)+1)), append(args, *find.UID) + } + if find.MemoID != nil { + where, args = append(where, "memo_id = "+placeholder(len(args)+1)), append(args, *find.MemoID) + } + + ms := &store.MemoShare{} + if err := d.db.QueryRowContext(ctx, ` + SELECT + id, + uid, + memo_id, + creator_id, + created_ts, + expires_ts + FROM memo_share + WHERE `+strings.Join(where, " AND ")+` + LIMIT 1`, + args..., + ).Scan( + &ms.ID, + &ms.UID, + &ms.MemoID, + &ms.CreatorID, + &ms.CreatedTs, + &ms.ExpiresTs, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return ms, nil +} + +func (d *DB) DeleteMemoShare(ctx context.Context, delete *store.DeleteMemoShare) error { + where, args := []string{"1 = 1"}, []any{} + if delete.ID != nil { + where, args = append(where, "id = "+placeholder(len(args)+1)), append(args, *delete.ID) + } + if delete.UID != nil { + where, args = append(where, "uid = "+placeholder(len(args)+1)), append(args, *delete.UID) + } + _, err := d.db.ExecContext(ctx, "DELETE FROM memo_share WHERE "+strings.Join(where, " AND "), args...) + return err +} diff --git a/store/db/sqlite/memo_share.go b/store/db/sqlite/memo_share.go new file mode 100644 index 000000000..c484ffce0 --- /dev/null +++ b/store/db/sqlite/memo_share.go @@ -0,0 +1,138 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "strings" + + "github.com/usememos/memos/store" +) + +func (d *DB) CreateMemoShare(ctx context.Context, create *store.MemoShare) (*store.MemoShare, error) { + fields := []string{"`uid`", "`memo_id`", "`creator_id`"} + placeholders := []string{"?", "?", "?"} + args := []any{create.UID, create.MemoID, create.CreatorID} + + if create.ExpiresTs != nil { + fields = append(fields, "`expires_ts`") + placeholders = append(placeholders, "?") + args = append(args, *create.ExpiresTs) + } + + stmt := "INSERT INTO `memo_share` (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(placeholders, ", ") + ") RETURNING `id`, `created_ts`" + if err := d.db.QueryRowContext(ctx, stmt, args...).Scan( + &create.ID, + &create.CreatedTs, + ); err != nil { + return nil, err + } + return create, nil +} + +func (d *DB) ListMemoShares(ctx context.Context, find *store.FindMemoShare) ([]*store.MemoShare, error) { + where, args := []string{"1 = 1"}, []any{} + + if find.ID != nil { + where, args = append(where, "`id` = ?"), append(args, *find.ID) + } + if find.UID != nil { + where, args = append(where, "`uid` = ?"), append(args, *find.UID) + } + if find.MemoID != nil { + where, args = append(where, "`memo_id` = ?"), append(args, *find.MemoID) + } + + rows, err := d.db.QueryContext(ctx, ` + SELECT + id, + uid, + memo_id, + creator_id, + created_ts, + expires_ts + FROM memo_share + WHERE `+strings.Join(where, " AND ")+` + ORDER BY id ASC`, + args..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + list := []*store.MemoShare{} + for rows.Next() { + ms := &store.MemoShare{} + if err := rows.Scan( + &ms.ID, + &ms.UID, + &ms.MemoID, + &ms.CreatorID, + &ms.CreatedTs, + &ms.ExpiresTs, + ); err != nil { + return nil, err + } + list = append(list, ms) + } + + if err := rows.Err(); err != nil { + return nil, err + } + return list, nil +} + +func (d *DB) GetMemoShare(ctx context.Context, find *store.FindMemoShare) (*store.MemoShare, error) { + where, args := []string{"1 = 1"}, []any{} + + if find.ID != nil { + where, args = append(where, "`id` = ?"), append(args, *find.ID) + } + if find.UID != nil { + where, args = append(where, "`uid` = ?"), append(args, *find.UID) + } + if find.MemoID != nil { + where, args = append(where, "`memo_id` = ?"), append(args, *find.MemoID) + } + + ms := &store.MemoShare{} + if err := d.db.QueryRowContext(ctx, ` + SELECT + id, + uid, + memo_id, + creator_id, + created_ts, + expires_ts + FROM memo_share + WHERE `+strings.Join(where, " AND ")+` + LIMIT 1`, + args..., + ).Scan( + &ms.ID, + &ms.UID, + &ms.MemoID, + &ms.CreatorID, + &ms.CreatedTs, + &ms.ExpiresTs, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return ms, nil +} + +func (d *DB) DeleteMemoShare(ctx context.Context, delete *store.DeleteMemoShare) error { + where, args := []string{"1 = 1"}, []any{} + if delete.ID != nil { + where, args = append(where, "`id` = ?"), append(args, *delete.ID) + } + if delete.UID != nil { + where, args = append(where, "`uid` = ?"), append(args, *delete.UID) + } + _, err := d.db.ExecContext(ctx, "DELETE FROM `memo_share` WHERE "+strings.Join(where, " AND "), args...) + return err +} diff --git a/store/driver.go b/store/driver.go index ee686c0e0..cd59012b5 100644 --- a/store/driver.go +++ b/store/driver.go @@ -63,4 +63,10 @@ type Driver interface { ListReactions(ctx context.Context, find *FindReaction) ([]*Reaction, error) GetReaction(ctx context.Context, find *FindReaction) (*Reaction, error) DeleteReaction(ctx context.Context, delete *DeleteReaction) error + + // MemoShare model related methods. + CreateMemoShare(ctx context.Context, create *MemoShare) (*MemoShare, error) + ListMemoShares(ctx context.Context, find *FindMemoShare) ([]*MemoShare, error) + GetMemoShare(ctx context.Context, find *FindMemoShare) (*MemoShare, error) + DeleteMemoShare(ctx context.Context, delete *DeleteMemoShare) error } diff --git a/store/memo_share.go b/store/memo_share.go new file mode 100644 index 000000000..89110dda6 --- /dev/null +++ b/store/memo_share.go @@ -0,0 +1,46 @@ +package store + +import "context" + +// MemoShare is an access grant that permits read-only access to a memo via a bearer token. +type MemoShare struct { + ID int32 + UID string + MemoID int32 + CreatorID int32 + CreatedTs int64 + ExpiresTs *int64 // nil means the share never expires +} + +// FindMemoShare is used to filter memo shares in list/get queries. +type FindMemoShare struct { + ID *int32 + UID *string + MemoID *int32 +} + +// DeleteMemoShare identifies a share grant to remove. +type DeleteMemoShare struct { + ID *int32 + UID *string +} + +// CreateMemoShare creates a new share grant. +func (s *Store) CreateMemoShare(ctx context.Context, create *MemoShare) (*MemoShare, error) { + return s.driver.CreateMemoShare(ctx, create) +} + +// ListMemoShares returns all share grants matching the filter. +func (s *Store) ListMemoShares(ctx context.Context, find *FindMemoShare) ([]*MemoShare, error) { + return s.driver.ListMemoShares(ctx, find) +} + +// GetMemoShare returns the first share grant matching the filter, or nil if none found. +func (s *Store) GetMemoShare(ctx context.Context, find *FindMemoShare) (*MemoShare, error) { + return s.driver.GetMemoShare(ctx, find) +} + +// DeleteMemoShare removes a share grant. +func (s *Store) DeleteMemoShare(ctx context.Context, delete *DeleteMemoShare) error { + return s.driver.DeleteMemoShare(ctx, delete) +} diff --git a/store/migration/mysql/0.27/04__memo_share.sql b/store/migration/mysql/0.27/04__memo_share.sql new file mode 100644 index 000000000..d532b8e1a --- /dev/null +++ b/store/migration/mysql/0.27/04__memo_share.sql @@ -0,0 +1,14 @@ +-- memo_share stores per-memo share grants (one row per share link). +-- uid is the opaque bearer token included in the share URL. +-- ON DELETE CASCADE ensures grants are cleaned up when the parent memo is deleted. +CREATE TABLE memo_share ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + uid VARCHAR(255) NOT NULL UNIQUE, + memo_id INT NOT NULL, + creator_id INT NOT NULL, + created_ts BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP()), + expires_ts BIGINT DEFAULT NULL, + FOREIGN KEY (memo_id) REFERENCES memo(id) ON DELETE CASCADE +); + +CREATE INDEX idx_memo_share_memo_id ON memo_share(memo_id); diff --git a/store/migration/mysql/LATEST.sql b/store/migration/mysql/LATEST.sql index 58b9b91dc..c9931b926 100644 --- a/store/migration/mysql/LATEST.sql +++ b/store/migration/mysql/LATEST.sql @@ -96,3 +96,16 @@ CREATE TABLE `reaction` ( `reaction_type` VARCHAR(256) NOT NULL, UNIQUE(`creator_id`,`content_id`,`reaction_type`) ); + +-- memo_share +CREATE TABLE `memo_share` ( + `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `uid` VARCHAR(255) NOT NULL UNIQUE, + `memo_id` INT NOT NULL, + `creator_id` INT NOT NULL, + `created_ts` BIGINT NOT NULL DEFAULT (UNIX_TIMESTAMP()), + `expires_ts` BIGINT DEFAULT NULL, + FOREIGN KEY (`memo_id`) REFERENCES `memo`(`id`) ON DELETE CASCADE +); + +CREATE INDEX `idx_memo_share_memo_id` ON `memo_share`(`memo_id`); diff --git a/store/migration/postgres/0.27/04__memo_share.sql b/store/migration/postgres/0.27/04__memo_share.sql new file mode 100644 index 000000000..1505c3ecc --- /dev/null +++ b/store/migration/postgres/0.27/04__memo_share.sql @@ -0,0 +1,14 @@ +-- memo_share stores per-memo share grants (one row per share link). +-- uid is the opaque bearer token included in the share URL. +-- ON DELETE CASCADE ensures grants are cleaned up when the parent memo is deleted. +CREATE TABLE memo_share ( + id SERIAL PRIMARY KEY, + uid TEXT NOT NULL UNIQUE, + memo_id INTEGER NOT NULL, + creator_id INTEGER NOT NULL, + created_ts BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()), + expires_ts BIGINT DEFAULT NULL, + FOREIGN KEY (memo_id) REFERENCES memo(id) ON DELETE CASCADE +); + +CREATE INDEX idx_memo_share_memo_id ON memo_share(memo_id); diff --git a/store/migration/postgres/LATEST.sql b/store/migration/postgres/LATEST.sql index 1affc26bf..502630856 100644 --- a/store/migration/postgres/LATEST.sql +++ b/store/migration/postgres/LATEST.sql @@ -96,3 +96,16 @@ CREATE TABLE reaction ( reaction_type TEXT NOT NULL, UNIQUE(creator_id, content_id, reaction_type) ); + +-- memo_share +CREATE TABLE memo_share ( + id SERIAL PRIMARY KEY, + uid TEXT NOT NULL UNIQUE, + memo_id INTEGER NOT NULL, + creator_id INTEGER NOT NULL, + created_ts BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()), + expires_ts BIGINT DEFAULT NULL, + FOREIGN KEY (memo_id) REFERENCES memo(id) ON DELETE CASCADE +); + +CREATE INDEX idx_memo_share_memo_id ON memo_share(memo_id); diff --git a/store/migration/sqlite/0.27/04__memo_share.sql b/store/migration/sqlite/0.27/04__memo_share.sql new file mode 100644 index 000000000..46673daab --- /dev/null +++ b/store/migration/sqlite/0.27/04__memo_share.sql @@ -0,0 +1,14 @@ +-- memo_share stores per-memo share grants (one row per share link). +-- uid is the opaque bearer token included in the share URL. +-- ON DELETE CASCADE ensures grants are cleaned up when the parent memo is deleted. +CREATE TABLE memo_share ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uid TEXT NOT NULL UNIQUE, + memo_id INTEGER NOT NULL, + creator_id INTEGER NOT NULL, + created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), + expires_ts BIGINT DEFAULT NULL, + FOREIGN KEY (memo_id) REFERENCES memo(id) ON DELETE CASCADE +); + +CREATE INDEX idx_memo_share_memo_id ON memo_share(memo_id); diff --git a/store/migration/sqlite/LATEST.sql b/store/migration/sqlite/LATEST.sql index bdb04054f..887e677ed 100644 --- a/store/migration/sqlite/LATEST.sql +++ b/store/migration/sqlite/LATEST.sql @@ -97,3 +97,16 @@ CREATE TABLE reaction ( reaction_type TEXT NOT NULL, UNIQUE(creator_id, content_id, reaction_type) ); + +-- memo_share +CREATE TABLE memo_share ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uid TEXT NOT NULL UNIQUE, + memo_id INTEGER NOT NULL, + creator_id INTEGER NOT NULL, + created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')), + expires_ts BIGINT DEFAULT NULL, + FOREIGN KEY (memo_id) REFERENCES memo(id) ON DELETE CASCADE +); + +CREATE INDEX idx_memo_share_memo_id ON memo_share(memo_id); diff --git a/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx b/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx index 3e72f77d3..570cf6594 100644 --- a/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx +++ b/web/src/components/MemoDetailSidebar/MemoDetailSidebar.tsx @@ -1,10 +1,15 @@ import { create } from "@bufbuild/protobuf"; import { timestampDate } from "@bufbuild/protobuf/wkt"; import { isEqual } from "lodash-es"; -import { CheckCircleIcon, Code2Icon, HashIcon, LinkIcon } from "lucide-react"; +import { CheckCircleIcon, Code2Icon, HashIcon, LinkIcon, Share2Icon } from "lucide-react"; +import { useState } from "react"; +import MemoSharePanel from "@/components/MemoSharePanel"; +import { Button } from "@/components/ui/button"; +import useCurrentUser from "@/hooks/useCurrentUser"; import { cn } from "@/lib/utils"; import { Memo, Memo_PropertySchema, MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb"; import { useTranslate } from "@/utils/i18n"; +import { isSuperUser } from "@/utils/user"; import MemoRelationForceGraph from "../MemoRelationForceGraph"; interface Props { @@ -19,12 +24,25 @@ const SectionLabel = ({ children }: { children: React.ReactNode }) => ( const MemoDetailSidebar = ({ memo, className, parentPage }: Props) => { const t = useTranslate(); + const currentUser = useCurrentUser(); + const [sharePanelOpen, setSharePanelOpen] = useState(false); const property = create(Memo_PropertySchema, memo.property || {}); const hasSpecialProperty = property.hasLink || property.hasTaskList || property.hasCode; const hasReferenceRelations = memo.relations.some((r) => r.type === MemoRelation_Type.REFERENCE); + const canManageShares = !memo.parent && (memo.creator === currentUser?.name || isSuperUser(currentUser)); return ( ); }; diff --git a/web/src/components/MemoSharePanel.tsx b/web/src/components/MemoSharePanel.tsx new file mode 100644 index 000000000..10a6504bf --- /dev/null +++ b/web/src/components/MemoSharePanel.tsx @@ -0,0 +1,158 @@ +import { timestampDate } from "@bufbuild/protobuf/wkt"; +import { ConnectError } from "@connectrpc/connect"; +import { CheckIcon, CopyIcon, LinkIcon, Loader2Icon, Trash2Icon } from "lucide-react"; +import { useState } from "react"; +import { toast } from "react-hot-toast"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { getShareUrl, useCreateMemoShare, useDeleteMemoShare, useMemoShares } from "@/hooks/useMemoShareQueries"; +import type { MemoShare } from "@/types/proto/api/v1/memo_service_pb"; +import { useTranslate } from "@/utils/i18n"; + +type ExpiryOption = "never" | "1d" | "7d" | "30d"; + +function getExpireDate(option: ExpiryOption): Date | undefined { + if (option === "never") return undefined; + const d = new Date(); + if (option === "1d") d.setDate(d.getDate() + 1); + else if (option === "7d") d.setDate(d.getDate() + 7); + else if (option === "30d") d.setDate(d.getDate() + 30); + return d; +} + +function formatExpiry(share: MemoShare, t: ReturnType): string { + if (!share.expireTime) return t("memo-share.never-expires"); + const d = timestampDate(share.expireTime); + return t("memo-share.expires-on", { date: d.toLocaleDateString() }); +} + +interface ShareLinkRowProps { + share: MemoShare; + memoName: string; +} + +function ShareLinkRow({ share, memoName }: ShareLinkRowProps) { + const t = useTranslate(); + const [copied, setCopied] = useState(false); + const deleteShare = useDeleteMemoShare(); + const url = getShareUrl(share); + + const handleCopy = async () => { + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const handleRevoke = async () => { + try { + await deleteShare.mutateAsync({ name: share.name, memoName }); + toast.success(t("memo-share.revoked")); + } catch (e) { + toast.error((e as ConnectError).message || t("memo-share.revoke-failed")); + } + }; + + return ( +
+
+ {url} +
+ + +
+
+

{formatExpiry(share, t)}

+
+ ); +} + +interface MemoSharePanelProps { + open: boolean; + onClose: () => void; + memoName: string; +} + +const MemoSharePanel = ({ open, onClose, memoName }: MemoSharePanelProps) => { + const t = useTranslate(); + const [expiry, setExpiry] = useState("never"); + const { data: shares = [], isLoading } = useMemoShares(memoName, { enabled: open }); + const createShare = useCreateMemoShare(); + + const handleCreate = async () => { + try { + await createShare.mutateAsync({ memoName, expireTime: getExpireDate(expiry) }); + } catch (e) { + toast.error((e as ConnectError).message || t("memo-share.create-failed")); + } + }; + + return ( + !v && onClose()}> + + + + + {t("memo-share.title")} + + + +
+ {/* Active links */} +
+

{t("memo-share.active-links")}

+ {isLoading ? ( + + ) : shares.length === 0 ? ( +

{t("memo-share.no-links")}

+ ) : ( +
+ {shares.map((share) => ( + + ))} +
+ )} +
+ + {/* Create new link */} +
+ + +
+
+
+
+ ); +}; + +export default MemoSharePanel; diff --git a/web/src/hooks/useMemoShareQueries.ts b/web/src/hooks/useMemoShareQueries.ts new file mode 100644 index 000000000..05720652d --- /dev/null +++ b/web/src/hooks/useMemoShareQueries.ts @@ -0,0 +1,92 @@ +import { create } from "@bufbuild/protobuf"; +import { timestampFromDate } from "@bufbuild/protobuf/wkt"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { memoServiceClient } from "@/connect"; +import type { MemoShare } from "@/types/proto/api/v1/memo_service_pb"; +import { + CreateMemoShareRequestSchema, + DeleteMemoShareRequestSchema, + GetMemoByShareRequestSchema, + ListMemoSharesRequestSchema, + MemoShareSchema, +} from "@/types/proto/api/v1/memo_service_pb"; + +// Query keys factory for share-related cache management. +export const memoShareKeys = { + all: ["memo-shares"] as const, + list: (memoName: string) => [...memoShareKeys.all, "list", memoName] as const, + byShare: (shareId: string) => [...memoShareKeys.all, "by-share", shareId] as const, +}; + +/** Lists all active share links for a memo (creator-only). */ +export function useMemoShares(memoName: string, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: memoShareKeys.list(memoName), + queryFn: async () => { + const response = await memoServiceClient.listMemoShares(create(ListMemoSharesRequestSchema, { parent: memoName })); + return response.memoShares; + }, + enabled: options?.enabled ?? !!memoName, + }); +} + +/** Creates a new share link for a memo. */ +export function useCreateMemoShare() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ memoName, expireTime }: { memoName: string; expireTime?: Date }) => { + const memoShare = create(MemoShareSchema, { + expireTime: expireTime ? timestampFromDate(expireTime) : undefined, + }); + const response = await memoServiceClient.createMemoShare(create(CreateMemoShareRequestSchema, { parent: memoName, memoShare })); + return response; + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: memoShareKeys.list(variables.memoName) }); + }, + }); +} + +/** Revokes (deletes) a share link. */ +export function useDeleteMemoShare() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ name, memoName }: { name: string; memoName: string }) => { + await memoServiceClient.deleteMemoShare(create(DeleteMemoShareRequestSchema, { name })); + return memoName; + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: memoShareKeys.list(variables.memoName) }); + }, + }); +} + +/** Resolves a share token to its memo. Used by the public SharedMemo page. */ +export function useSharedMemo(shareId: string, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: memoShareKeys.byShare(shareId), + queryFn: async () => { + const memo = await memoServiceClient.getMemoByShare(create(GetMemoByShareRequestSchema, { shareId })); + return memo; + }, + enabled: options?.enabled ?? !!shareId, + retry: false, // Don't retry NOT_FOUND — the link is invalid or expired + }); +} + +/** + * Returns the share URL for a MemoShare resource. + * The token is the last path segment of the share name (memos/{uid}/shares/{token}). + */ +export function getShareUrl(share: MemoShare): string { + const token = share.name.split("/").pop() ?? ""; + return `${window.location.origin}/memos/shares/${token}`; +} + +/** + * Returns the token portion of a MemoShare resource name. + * Format: memos/{memo}/shares/{token} + */ +export function getShareToken(share: MemoShare): string { + return share.name.split("/").pop() ?? ""; +} diff --git a/web/src/i18n.ts b/web/src/i18n.ts index 1febe5953..9ae10c7d2 100644 --- a/web/src/i18n.ts +++ b/web/src/i18n.ts @@ -51,11 +51,17 @@ const LazyImportPlugin: BackendModule = { read: function (language, _, callback) { const matchedLanguage = findNearestMatchedLanguage(language); import(`./locales/${matchedLanguage}.json`) - .then((translation: Record) => { - callback(null, translation); + .then((translationModule: Record) => { + callback(null, (translationModule.default as Record) ?? translationModule); }) .catch(() => { - // Fallback to English. + import("./locales/en.json") + .then((translationModule: Record) => { + callback(null, (translationModule.default as Record) ?? translationModule); + }) + .catch((error: unknown) => { + callback(error as Error, false); + }); }); }, }; @@ -67,6 +73,9 @@ i18n detection: { order: ["navigator"], }, + interpolation: { + escapeValue: false, + }, fallbackLng: { ...fallbacks, ...{ default: ["en"] }, diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 4607c61e4..86afe2d98 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -468,5 +468,30 @@ "connected": "Live updates active", "connecting": "Connecting to live updates...", "disconnected": "Live updates unavailable" + }, + "memo-share": { + "share": "Share", + "section-label": "Sharing", + "open-panel": "Manage share links", + "title": "Share this memo", + "active-links": "Active share links", + "no-links": "No share links yet. Create one below.", + "create-link": "Create new link", + "copy": "Copy link", + "copied": "Copied!", + "revoke": "Revoke", + "expiry-label": "Expires", + "expiry-never": "Never", + "expiry-1-day": "1 day", + "expiry-7-days": "7 days", + "expiry-30-days": "30 days", + "never-expires": "Never expires", + "expires-on": "Expires {{date}}", + "creating": "Creating…", + "revoked": "Share link revoked", + "revoke-failed": "Failed to revoke link", + "create-failed": "Failed to create share link", + "invalid-link": "This link is invalid or has expired.", + "shared-by": "Shared by {{creator}}" } } diff --git a/web/src/pages/SharedMemo.tsx b/web/src/pages/SharedMemo.tsx new file mode 100644 index 000000000..63650e78f --- /dev/null +++ b/web/src/pages/SharedMemo.tsx @@ -0,0 +1,73 @@ +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { timestampDate } from "@bufbuild/protobuf/wkt"; +import { Code, ConnectError } from "@connectrpc/connect"; +import { AlertCircleIcon } from "lucide-react"; +import { useParams } from "react-router-dom"; +import MemoContent from "@/components/MemoContent"; +import AttachmentList from "@/components/MemoView/components/metadata/AttachmentList"; +import UserAvatar from "@/components/UserAvatar"; +import { useSharedMemo } from "@/hooks/useMemoShareQueries"; +import { useUser } from "@/hooks/useUserQueries"; +import i18n from "@/i18n"; +import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; +import { useTranslate } from "@/utils/i18n"; + +function withShareAttachmentLinks(attachments: Attachment[], token: string): Attachment[] { + return attachments.map((a) => { + if (a.externalLink) return a; + return { ...a, externalLink: `${window.location.origin}/file/${a.name}/${a.filename}?share_token=${encodeURIComponent(token)}` }; + }); +} + +const SharedMemo = () => { + const t = useTranslate(); + const { token = "" } = useParams<{ token: string }>(); + + const { data: memo, error, isLoading } = useSharedMemo(token, { enabled: !!token }); + const { data: creator } = useUser(memo?.creator ?? "", { enabled: !!memo?.creator }); + + const isNotFound = error instanceof ConnectError && (error.code === Code.NotFound || error.code === Code.Unauthenticated); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (isNotFound || (!isLoading && !memo)) { + return ( +
+ +

{t("memo-share.invalid-link")}

+
+ ); + } + + if (error || !memo) return null; + + const displayDate = (memo.displayTime as Timestamp | undefined) + ? timestampDate(memo.displayTime as Timestamp)?.toLocaleString(i18n.language) + : null; + + return ( +
+ {/* Creator + date above the card */} +
+
+ + {creator?.displayName || creator?.username || memo.creator} +
+ {displayDate && {displayDate}} +
+ +
+ + {memo.attachments.length > 0 && } +
+
+ ); +}; + +export default SharedMemo; diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index 960631bc3..44975bc55 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -33,6 +33,7 @@ const NotFound = lazyWithReload(() => import("@/pages/NotFound")); const PermissionDenied = lazyWithReload(() => import("@/pages/PermissionDenied")); const Attachments = lazyWithReload(() => import("@/pages/Attachments")); const Setting = lazyWithReload(() => import("@/pages/Setting")); +const SharedMemo = lazyWithReload(() => import("@/pages/SharedMemo")); const SignIn = lazyWithReload(() => import("@/pages/SignIn")); const SignUp = lazyWithReload(() => import("@/pages/SignUp")); const UserProfile = lazyWithReload(() => import("@/pages/UserProfile")); @@ -80,6 +81,9 @@ const router = createBrowserRouter([ { path: "*", element: }, ], }, + // Public share-link viewer — outside RootLayout to bypass auth-gating + // (including when disallowPublicVisibility is enabled on the instance) + { path: "memos/shares/:token", element: }, ], }, ]); diff --git a/web/src/router/routes.ts b/web/src/router/routes.ts index 74502cc2a..48991636b 100644 --- a/web/src/router/routes.ts +++ b/web/src/router/routes.ts @@ -6,6 +6,7 @@ export const ROUTES = { SETTING: "/setting", EXPLORE: "/explore", AUTH: "/auth", + SHARED_MEMO: "/memos/shares", } as const; export type RouteKey = keyof typeof ROUTES; diff --git a/web/src/types/proto/api/v1/memo_service_pb.ts b/web/src/types/proto/api/v1/memo_service_pb.ts index 883d8fc6f..e4bf870d9 100644 --- a/web/src/types/proto/api/v1/memo_service_pb.ts +++ b/web/src/types/proto/api/v1/memo_service_pb.ts @@ -20,7 +20,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file api/v1/memo_service.proto. */ export const file_api_v1_memo_service: GenFile = /*@__PURE__*/ - fileDesc("ChlhcGkvdjEvbWVtb19zZXJ2aWNlLnByb3RvEgxtZW1vcy5hcGkudjEipwIKCFJlYWN0aW9uEhQKBG5hbWUYASABKAlCBuBBA+BBCBIqCgdjcmVhdG9yGAIgASgJQhngQQP6QRMKEW1lbW9zLmFwaS52MS9Vc2VyEi0KCmNvbnRlbnRfaWQYAyABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL01lbW8SGgoNcmVhY3Rpb25fdHlwZRgEIAEoCUID4EECEjQKC2NyZWF0ZV90aW1lGAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEID4EEDOljqQVUKFW1lbW9zLmFwaS52MS9SZWFjdGlvbhIhbWVtb3Mve21lbW99L3JlYWN0aW9ucy97cmVhY3Rpb259GgRuYW1lKglyZWFjdGlvbnMyCHJlYWN0aW9uIo0HCgRNZW1vEhEKBG5hbWUYASABKAlCA+BBCBInCgVzdGF0ZRgCIAEoDjITLm1lbW9zLmFwaS52MS5TdGF0ZUID4EECEioKB2NyZWF0b3IYAyABKAlCGeBBA/pBEwoRbWVtb3MuYXBpLnYxL1VzZXISNAoLY3JlYXRlX3RpbWUYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgPgQQESNAoLdXBkYXRlX3RpbWUYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgPgQQESNQoMZGlzcGxheV90aW1lGAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEID4EEBEhQKB2NvbnRlbnQYByABKAlCA+BBAhIxCgp2aXNpYmlsaXR5GAkgASgOMhgubWVtb3MuYXBpLnYxLlZpc2liaWxpdHlCA+BBAhIRCgR0YWdzGAogAygJQgPgQQMSEwoGcGlubmVkGAsgASgIQgPgQQESMgoLYXR0YWNobWVudHMYDCADKAsyGC5tZW1vcy5hcGkudjEuQXR0YWNobWVudEID4EEBEjIKCXJlbGF0aW9ucxgNIAMoCzIaLm1lbW9zLmFwaS52MS5NZW1vUmVsYXRpb25CA+BBARIuCglyZWFjdGlvbnMYDiADKAsyFi5tZW1vcy5hcGkudjEuUmVhY3Rpb25CA+BBAxIyCghwcm9wZXJ0eRgPIAEoCzIbLm1lbW9zLmFwaS52MS5NZW1vLlByb3BlcnR5QgPgQQMSLgoGcGFyZW50GBAgASgJQhngQQP6QRMKEW1lbW9zLmFwaS52MS9NZW1vSACIAQESFAoHc25pcHBldBgRIAEoCUID4EEDEjIKCGxvY2F0aW9uGBIgASgLMhYubWVtb3MuYXBpLnYxLkxvY2F0aW9uQgPgQQFIAYgBARpyCghQcm9wZXJ0eRIQCghoYXNfbGluaxgBIAEoCBIVCg1oYXNfdGFza19saXN0GAIgASgIEhAKCGhhc19jb2RlGAMgASgIEhwKFGhhc19pbmNvbXBsZXRlX3Rhc2tzGAQgASgIEg0KBXRpdGxlGAUgASgJOjfqQTQKEW1lbW9zLmFwaS52MS9NZW1vEgxtZW1vcy97bWVtb30aBG5hbWUqBW1lbW9zMgRtZW1vQgkKB19wYXJlbnRCCwoJX2xvY2F0aW9uIlMKCExvY2F0aW9uEhgKC3BsYWNlaG9sZGVyGAEgASgJQgPgQQESFQoIbGF0aXR1ZGUYAiABKAFCA+BBARIWCglsb25naXR1ZGUYAyABKAFCA+BBASJQChFDcmVhdGVNZW1vUmVxdWVzdBIlCgRtZW1vGAEgASgLMhIubWVtb3MuYXBpLnYxLk1lbW9CA+BBAhIUCgdtZW1vX2lkGAIgASgJQgPgQQEiswEKEExpc3RNZW1vc1JlcXVlc3QSFgoJcGFnZV9zaXplGAEgASgFQgPgQQESFwoKcGFnZV90b2tlbhgCIAEoCUID4EEBEicKBXN0YXRlGAMgASgOMhMubWVtb3MuYXBpLnYxLlN0YXRlQgPgQQESFQoIb3JkZXJfYnkYBCABKAlCA+BBARITCgZmaWx0ZXIYBSABKAlCA+BBARIZCgxzaG93X2RlbGV0ZWQYBiABKAhCA+BBASJPChFMaXN0TWVtb3NSZXNwb25zZRIhCgVtZW1vcxgBIAMoCzISLm1lbW9zLmFwaS52MS5NZW1vEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCSI5Cg5HZXRNZW1vUmVxdWVzdBInCgRuYW1lGAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9NZW1vInAKEVVwZGF0ZU1lbW9SZXF1ZXN0EiUKBG1lbW8YASABKAsyEi5tZW1vcy5hcGkudjEuTWVtb0ID4EECEjQKC3VwZGF0ZV9tYXNrGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLkZpZWxkTWFza0ID4EECIlAKEURlbGV0ZU1lbW9SZXF1ZXN0EicKBG5hbWUYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL01lbW8SEgoFZm9yY2UYAiABKAhCA+BBASJ4ChlTZXRNZW1vQXR0YWNobWVudHNSZXF1ZXN0EicKBG5hbWUYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL01lbW8SMgoLYXR0YWNobWVudHMYAiADKAsyGC5tZW1vcy5hcGkudjEuQXR0YWNobWVudEID4EECInYKGkxpc3RNZW1vQXR0YWNobWVudHNSZXF1ZXN0EicKBG5hbWUYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL01lbW8SFgoJcGFnZV9zaXplGAIgASgFQgPgQQESFwoKcGFnZV90b2tlbhgDIAEoCUID4EEBImUKG0xpc3RNZW1vQXR0YWNobWVudHNSZXNwb25zZRItCgthdHRhY2htZW50cxgBIAMoCzIYLm1lbW9zLmFwaS52MS5BdHRhY2htZW50EhcKD25leHRfcGFnZV90b2tlbhgCIAEoCSKzAgoMTWVtb1JlbGF0aW9uEjIKBG1lbW8YASABKAsyHy5tZW1vcy5hcGkudjEuTWVtb1JlbGF0aW9uLk1lbW9CA+BBAhI6CgxyZWxhdGVkX21lbW8YAiABKAsyHy5tZW1vcy5hcGkudjEuTWVtb1JlbGF0aW9uLk1lbW9CA+BBAhIyCgR0eXBlGAMgASgOMh8ubWVtb3MuYXBpLnYxLk1lbW9SZWxhdGlvbi5UeXBlQgPgQQIaRQoETWVtbxInCgRuYW1lGAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9NZW1vEhQKB3NuaXBwZXQYAiABKAlCA+BBAyI4CgRUeXBlEhQKEFRZUEVfVU5TUEVDSUZJRUQQABINCglSRUZFUkVOQ0UQARILCgdDT01NRU5UEAIidgoXU2V0TWVtb1JlbGF0aW9uc1JlcXVlc3QSJwoEbmFtZRgBIAEoCUIZ4EEC+kETChFtZW1vcy5hcGkudjEvTWVtbxIyCglyZWxhdGlvbnMYAiADKAsyGi5tZW1vcy5hcGkudjEuTWVtb1JlbGF0aW9uQgPgQQIidAoYTGlzdE1lbW9SZWxhdGlvbnNSZXF1ZXN0EicKBG5hbWUYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL01lbW8SFgoJcGFnZV9zaXplGAIgASgFQgPgQQESFwoKcGFnZV90b2tlbhgDIAEoCUID4EEBImMKGUxpc3RNZW1vUmVsYXRpb25zUmVzcG9uc2USLQoJcmVsYXRpb25zGAEgAygLMhoubWVtb3MuYXBpLnYxLk1lbW9SZWxhdGlvbhIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkihgEKGENyZWF0ZU1lbW9Db21tZW50UmVxdWVzdBInCgRuYW1lGAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9NZW1vEigKB2NvbW1lbnQYAiABKAsyEi5tZW1vcy5hcGkudjEuTWVtb0ID4EECEhcKCmNvbW1lbnRfaWQYAyABKAlCA+BBASKKAQoXTGlzdE1lbW9Db21tZW50c1JlcXVlc3QSJwoEbmFtZRgBIAEoCUIZ4EEC+kETChFtZW1vcy5hcGkudjEvTWVtbxIWCglwYWdlX3NpemUYAiABKAVCA+BBARIXCgpwYWdlX3Rva2VuGAMgASgJQgPgQQESFQoIb3JkZXJfYnkYBCABKAlCA+BBASJqChhMaXN0TWVtb0NvbW1lbnRzUmVzcG9uc2USIQoFbWVtb3MYASADKAsyEi5tZW1vcy5hcGkudjEuTWVtbxIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkSEgoKdG90YWxfc2l6ZRgDIAEoBSJ0ChhMaXN0TWVtb1JlYWN0aW9uc1JlcXVlc3QSJwoEbmFtZRgBIAEoCUIZ4EEC+kETChFtZW1vcy5hcGkudjEvTWVtbxIWCglwYWdlX3NpemUYAiABKAVCA+BBARIXCgpwYWdlX3Rva2VuGAMgASgJQgPgQQEicwoZTGlzdE1lbW9SZWFjdGlvbnNSZXNwb25zZRIpCglyZWFjdGlvbnMYASADKAsyFi5tZW1vcy5hcGkudjEuUmVhY3Rpb24SFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJEhIKCnRvdGFsX3NpemUYAyABKAUicwoZVXBzZXJ0TWVtb1JlYWN0aW9uUmVxdWVzdBInCgRuYW1lGAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9NZW1vEi0KCHJlYWN0aW9uGAIgASgLMhYubWVtb3MuYXBpLnYxLlJlYWN0aW9uQgPgQQIiSAoZRGVsZXRlTWVtb1JlYWN0aW9uUmVxdWVzdBIrCgRuYW1lGAEgASgJQh3gQQL6QRcKFW1lbW9zLmFwaS52MS9SZWFjdGlvbipQCgpWaXNpYmlsaXR5EhoKFlZJU0lCSUxJVFlfVU5TUEVDSUZJRUQQABILCgdQUklWQVRFEAESDQoJUFJPVEVDVEVEEAISCgoGUFVCTElDEAMy0w4KC01lbW9TZXJ2aWNlEmUKCkNyZWF0ZU1lbW8SHy5tZW1vcy5hcGkudjEuQ3JlYXRlTWVtb1JlcXVlc3QaEi5tZW1vcy5hcGkudjEuTWVtbyIi2kEEbWVtb4LT5JMCFToEbWVtbyINL2FwaS92MS9tZW1vcxJmCglMaXN0TWVtb3MSHi5tZW1vcy5hcGkudjEuTGlzdE1lbW9zUmVxdWVzdBofLm1lbW9zLmFwaS52MS5MaXN0TWVtb3NSZXNwb25zZSIY2kEAgtPkkwIPEg0vYXBpL3YxL21lbW9zEmIKB0dldE1lbW8SHC5tZW1vcy5hcGkudjEuR2V0TWVtb1JlcXVlc3QaEi5tZW1vcy5hcGkudjEuTWVtbyIl2kEEbmFtZYLT5JMCGBIWL2FwaS92MS97bmFtZT1tZW1vcy8qfRJ/CgpVcGRhdGVNZW1vEh8ubWVtb3MuYXBpLnYxLlVwZGF0ZU1lbW9SZXF1ZXN0GhIubWVtb3MuYXBpLnYxLk1lbW8iPNpBEG1lbW8sdXBkYXRlX21hc2uC0+STAiM6BG1lbW8yGy9hcGkvdjEve21lbW8ubmFtZT1tZW1vcy8qfRJsCgpEZWxldGVNZW1vEh8ubWVtb3MuYXBpLnYxLkRlbGV0ZU1lbW9SZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IiXaQQRuYW1lgtPkkwIYKhYvYXBpL3YxL3tuYW1lPW1lbW9zLyp9EosBChJTZXRNZW1vQXR0YWNobWVudHMSJy5tZW1vcy5hcGkudjEuU2V0TWVtb0F0dGFjaG1lbnRzUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSI02kEEbmFtZYLT5JMCJzoBKjIiL2FwaS92MS97bmFtZT1tZW1vcy8qfS9hdHRhY2htZW50cxKdAQoTTGlzdE1lbW9BdHRhY2htZW50cxIoLm1lbW9zLmFwaS52MS5MaXN0TWVtb0F0dGFjaG1lbnRzUmVxdWVzdBopLm1lbW9zLmFwaS52MS5MaXN0TWVtb0F0dGFjaG1lbnRzUmVzcG9uc2UiMdpBBG5hbWWC0+STAiQSIi9hcGkvdjEve25hbWU9bWVtb3MvKn0vYXR0YWNobWVudHMShQEKEFNldE1lbW9SZWxhdGlvbnMSJS5tZW1vcy5hcGkudjEuU2V0TWVtb1JlbGF0aW9uc1JlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiMtpBBG5hbWWC0+STAiU6ASoyIC9hcGkvdjEve25hbWU9bWVtb3MvKn0vcmVsYXRpb25zEpUBChFMaXN0TWVtb1JlbGF0aW9ucxImLm1lbW9zLmFwaS52MS5MaXN0TWVtb1JlbGF0aW9uc1JlcXVlc3QaJy5tZW1vcy5hcGkudjEuTGlzdE1lbW9SZWxhdGlvbnNSZXNwb25zZSIv2kEEbmFtZYLT5JMCIhIgL2FwaS92MS97bmFtZT1tZW1vcy8qfS9yZWxhdGlvbnMSkAEKEUNyZWF0ZU1lbW9Db21tZW50EiYubWVtb3MuYXBpLnYxLkNyZWF0ZU1lbW9Db21tZW50UmVxdWVzdBoSLm1lbW9zLmFwaS52MS5NZW1vIj/aQQxuYW1lLGNvbW1lbnSC0+STAio6B2NvbW1lbnQiHy9hcGkvdjEve25hbWU9bWVtb3MvKn0vY29tbWVudHMSkQEKEExpc3RNZW1vQ29tbWVudHMSJS5tZW1vcy5hcGkudjEuTGlzdE1lbW9Db21tZW50c1JlcXVlc3QaJi5tZW1vcy5hcGkudjEuTGlzdE1lbW9Db21tZW50c1Jlc3BvbnNlIi7aQQRuYW1lgtPkkwIhEh8vYXBpL3YxL3tuYW1lPW1lbW9zLyp9L2NvbW1lbnRzEpUBChFMaXN0TWVtb1JlYWN0aW9ucxImLm1lbW9zLmFwaS52MS5MaXN0TWVtb1JlYWN0aW9uc1JlcXVlc3QaJy5tZW1vcy5hcGkudjEuTGlzdE1lbW9SZWFjdGlvbnNSZXNwb25zZSIv2kEEbmFtZYLT5JMCIhIgL2FwaS92MS97bmFtZT1tZW1vcy8qfS9yZWFjdGlvbnMSiQEKElVwc2VydE1lbW9SZWFjdGlvbhInLm1lbW9zLmFwaS52MS5VcHNlcnRNZW1vUmVhY3Rpb25SZXF1ZXN0GhYubWVtb3MuYXBpLnYxLlJlYWN0aW9uIjLaQQRuYW1lgtPkkwIlOgEqIiAvYXBpL3YxL3tuYW1lPW1lbW9zLyp9L3JlYWN0aW9ucxKIAQoSRGVsZXRlTWVtb1JlYWN0aW9uEicubWVtb3MuYXBpLnYxLkRlbGV0ZU1lbW9SZWFjdGlvblJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiMdpBBG5hbWWC0+STAiQqIi9hcGkvdjEve25hbWU9bWVtb3MvKi9yZWFjdGlvbnMvKn1CqAEKEGNvbS5tZW1vcy5hcGkudjFCEE1lbW9TZXJ2aWNlUHJvdG9QAVowZ2l0aHViLmNvbS91c2VtZW1vcy9tZW1vcy9wcm90by9nZW4vYXBpL3YxO2FwaXYxogIDTUFYqgIMTWVtb3MuQXBpLlYxygIMTWVtb3NcQXBpXFYx4gIYTWVtb3NcQXBpXFYxXEdQQk1ldGFkYXRh6gIOTWVtb3M6OkFwaTo6VjFiBnByb3RvMw", [file_api_v1_attachment_service, file_api_v1_common, file_google_api_annotations, file_google_api_client, file_google_api_field_behavior, file_google_api_resource, file_google_protobuf_empty, file_google_protobuf_field_mask, file_google_protobuf_timestamp]); + fileDesc("ChlhcGkvdjEvbWVtb19zZXJ2aWNlLnByb3RvEgxtZW1vcy5hcGkudjEipwIKCFJlYWN0aW9uEhQKBG5hbWUYASABKAlCBuBBA+BBCBIqCgdjcmVhdG9yGAIgASgJQhngQQP6QRMKEW1lbW9zLmFwaS52MS9Vc2VyEi0KCmNvbnRlbnRfaWQYAyABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL01lbW8SGgoNcmVhY3Rpb25fdHlwZRgEIAEoCUID4EECEjQKC2NyZWF0ZV90aW1lGAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEID4EEDOljqQVUKFW1lbW9zLmFwaS52MS9SZWFjdGlvbhIhbWVtb3Mve21lbW99L3JlYWN0aW9ucy97cmVhY3Rpb259GgRuYW1lKglyZWFjdGlvbnMyCHJlYWN0aW9uIo0HCgRNZW1vEhEKBG5hbWUYASABKAlCA+BBCBInCgVzdGF0ZRgCIAEoDjITLm1lbW9zLmFwaS52MS5TdGF0ZUID4EECEioKB2NyZWF0b3IYAyABKAlCGeBBA/pBEwoRbWVtb3MuYXBpLnYxL1VzZXISNAoLY3JlYXRlX3RpbWUYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgPgQQESNAoLdXBkYXRlX3RpbWUYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgPgQQESNQoMZGlzcGxheV90aW1lGAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEID4EEBEhQKB2NvbnRlbnQYByABKAlCA+BBAhIxCgp2aXNpYmlsaXR5GAkgASgOMhgubWVtb3MuYXBpLnYxLlZpc2liaWxpdHlCA+BBAhIRCgR0YWdzGAogAygJQgPgQQMSEwoGcGlubmVkGAsgASgIQgPgQQESMgoLYXR0YWNobWVudHMYDCADKAsyGC5tZW1vcy5hcGkudjEuQXR0YWNobWVudEID4EEBEjIKCXJlbGF0aW9ucxgNIAMoCzIaLm1lbW9zLmFwaS52MS5NZW1vUmVsYXRpb25CA+BBARIuCglyZWFjdGlvbnMYDiADKAsyFi5tZW1vcy5hcGkudjEuUmVhY3Rpb25CA+BBAxIyCghwcm9wZXJ0eRgPIAEoCzIbLm1lbW9zLmFwaS52MS5NZW1vLlByb3BlcnR5QgPgQQMSLgoGcGFyZW50GBAgASgJQhngQQP6QRMKEW1lbW9zLmFwaS52MS9NZW1vSACIAQESFAoHc25pcHBldBgRIAEoCUID4EEDEjIKCGxvY2F0aW9uGBIgASgLMhYubWVtb3MuYXBpLnYxLkxvY2F0aW9uQgPgQQFIAYgBARpyCghQcm9wZXJ0eRIQCghoYXNfbGluaxgBIAEoCBIVCg1oYXNfdGFza19saXN0GAIgASgIEhAKCGhhc19jb2RlGAMgASgIEhwKFGhhc19pbmNvbXBsZXRlX3Rhc2tzGAQgASgIEg0KBXRpdGxlGAUgASgJOjfqQTQKEW1lbW9zLmFwaS52MS9NZW1vEgxtZW1vcy97bWVtb30aBG5hbWUqBW1lbW9zMgRtZW1vQgkKB19wYXJlbnRCCwoJX2xvY2F0aW9uIlMKCExvY2F0aW9uEhgKC3BsYWNlaG9sZGVyGAEgASgJQgPgQQESFQoIbGF0aXR1ZGUYAiABKAFCA+BBARIWCglsb25naXR1ZGUYAyABKAFCA+BBASJQChFDcmVhdGVNZW1vUmVxdWVzdBIlCgRtZW1vGAEgASgLMhIubWVtb3MuYXBpLnYxLk1lbW9CA+BBAhIUCgdtZW1vX2lkGAIgASgJQgPgQQEiswEKEExpc3RNZW1vc1JlcXVlc3QSFgoJcGFnZV9zaXplGAEgASgFQgPgQQESFwoKcGFnZV90b2tlbhgCIAEoCUID4EEBEicKBXN0YXRlGAMgASgOMhMubWVtb3MuYXBpLnYxLlN0YXRlQgPgQQESFQoIb3JkZXJfYnkYBCABKAlCA+BBARITCgZmaWx0ZXIYBSABKAlCA+BBARIZCgxzaG93X2RlbGV0ZWQYBiABKAhCA+BBASJPChFMaXN0TWVtb3NSZXNwb25zZRIhCgVtZW1vcxgBIAMoCzISLm1lbW9zLmFwaS52MS5NZW1vEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCSI5Cg5HZXRNZW1vUmVxdWVzdBInCgRuYW1lGAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9NZW1vInAKEVVwZGF0ZU1lbW9SZXF1ZXN0EiUKBG1lbW8YASABKAsyEi5tZW1vcy5hcGkudjEuTWVtb0ID4EECEjQKC3VwZGF0ZV9tYXNrGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLkZpZWxkTWFza0ID4EECIlAKEURlbGV0ZU1lbW9SZXF1ZXN0EicKBG5hbWUYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL01lbW8SEgoFZm9yY2UYAiABKAhCA+BBASJ4ChlTZXRNZW1vQXR0YWNobWVudHNSZXF1ZXN0EicKBG5hbWUYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL01lbW8SMgoLYXR0YWNobWVudHMYAiADKAsyGC5tZW1vcy5hcGkudjEuQXR0YWNobWVudEID4EECInYKGkxpc3RNZW1vQXR0YWNobWVudHNSZXF1ZXN0EicKBG5hbWUYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL01lbW8SFgoJcGFnZV9zaXplGAIgASgFQgPgQQESFwoKcGFnZV90b2tlbhgDIAEoCUID4EEBImUKG0xpc3RNZW1vQXR0YWNobWVudHNSZXNwb25zZRItCgthdHRhY2htZW50cxgBIAMoCzIYLm1lbW9zLmFwaS52MS5BdHRhY2htZW50EhcKD25leHRfcGFnZV90b2tlbhgCIAEoCSKzAgoMTWVtb1JlbGF0aW9uEjIKBG1lbW8YASABKAsyHy5tZW1vcy5hcGkudjEuTWVtb1JlbGF0aW9uLk1lbW9CA+BBAhI6CgxyZWxhdGVkX21lbW8YAiABKAsyHy5tZW1vcy5hcGkudjEuTWVtb1JlbGF0aW9uLk1lbW9CA+BBAhIyCgR0eXBlGAMgASgOMh8ubWVtb3MuYXBpLnYxLk1lbW9SZWxhdGlvbi5UeXBlQgPgQQIaRQoETWVtbxInCgRuYW1lGAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9NZW1vEhQKB3NuaXBwZXQYAiABKAlCA+BBAyI4CgRUeXBlEhQKEFRZUEVfVU5TUEVDSUZJRUQQABINCglSRUZFUkVOQ0UQARILCgdDT01NRU5UEAIidgoXU2V0TWVtb1JlbGF0aW9uc1JlcXVlc3QSJwoEbmFtZRgBIAEoCUIZ4EEC+kETChFtZW1vcy5hcGkudjEvTWVtbxIyCglyZWxhdGlvbnMYAiADKAsyGi5tZW1vcy5hcGkudjEuTWVtb1JlbGF0aW9uQgPgQQIidAoYTGlzdE1lbW9SZWxhdGlvbnNSZXF1ZXN0EicKBG5hbWUYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL01lbW8SFgoJcGFnZV9zaXplGAIgASgFQgPgQQESFwoKcGFnZV90b2tlbhgDIAEoCUID4EEBImMKGUxpc3RNZW1vUmVsYXRpb25zUmVzcG9uc2USLQoJcmVsYXRpb25zGAEgAygLMhoubWVtb3MuYXBpLnYxLk1lbW9SZWxhdGlvbhIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkihgEKGENyZWF0ZU1lbW9Db21tZW50UmVxdWVzdBInCgRuYW1lGAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9NZW1vEigKB2NvbW1lbnQYAiABKAsyEi5tZW1vcy5hcGkudjEuTWVtb0ID4EECEhcKCmNvbW1lbnRfaWQYAyABKAlCA+BBASKKAQoXTGlzdE1lbW9Db21tZW50c1JlcXVlc3QSJwoEbmFtZRgBIAEoCUIZ4EEC+kETChFtZW1vcy5hcGkudjEvTWVtbxIWCglwYWdlX3NpemUYAiABKAVCA+BBARIXCgpwYWdlX3Rva2VuGAMgASgJQgPgQQESFQoIb3JkZXJfYnkYBCABKAlCA+BBASJqChhMaXN0TWVtb0NvbW1lbnRzUmVzcG9uc2USIQoFbWVtb3MYASADKAsyEi5tZW1vcy5hcGkudjEuTWVtbxIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkSEgoKdG90YWxfc2l6ZRgDIAEoBSJ0ChhMaXN0TWVtb1JlYWN0aW9uc1JlcXVlc3QSJwoEbmFtZRgBIAEoCUIZ4EEC+kETChFtZW1vcy5hcGkudjEvTWVtbxIWCglwYWdlX3NpemUYAiABKAVCA+BBARIXCgpwYWdlX3Rva2VuGAMgASgJQgPgQQEicwoZTGlzdE1lbW9SZWFjdGlvbnNSZXNwb25zZRIpCglyZWFjdGlvbnMYASADKAsyFi5tZW1vcy5hcGkudjEuUmVhY3Rpb24SFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJEhIKCnRvdGFsX3NpemUYAyABKAUicwoZVXBzZXJ0TWVtb1JlYWN0aW9uUmVxdWVzdBInCgRuYW1lGAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9NZW1vEi0KCHJlYWN0aW9uGAIgASgLMhYubWVtb3MuYXBpLnYxLlJlYWN0aW9uQgPgQQIiSAoZRGVsZXRlTWVtb1JlYWN0aW9uUmVxdWVzdBIrCgRuYW1lGAEgASgJQh3gQQL6QRcKFW1lbW9zLmFwaS52MS9SZWFjdGlvbiLoAQoJTWVtb1NoYXJlEhEKBG5hbWUYASABKAlCA+BBCBI0CgtjcmVhdGVfdGltZRgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAxI5CgtleHBpcmVfdGltZRgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAUgAiAEBOkfqQUQKFm1lbW9zLmFwaS52MS9NZW1vU2hhcmUSG21lbW9zL3ttZW1vfS9zaGFyZXMve3NoYXJlfSoGc2hhcmVzMgVzaGFyZUIOCgxfZXhwaXJlX3RpbWUidQoWQ3JlYXRlTWVtb1NoYXJlUmVxdWVzdBIpCgZwYXJlbnQYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL01lbW8SMAoKbWVtb19zaGFyZRgCIAEoCzIXLm1lbW9zLmFwaS52MS5NZW1vU2hhcmVCA+BBAiJCChVMaXN0TWVtb1NoYXJlc1JlcXVlc3QSKQoGcGFyZW50GAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9NZW1vIkYKFkxpc3RNZW1vU2hhcmVzUmVzcG9uc2USLAoLbWVtb19zaGFyZXMYASADKAsyFy5tZW1vcy5hcGkudjEuTWVtb1NoYXJlIkYKFkRlbGV0ZU1lbW9TaGFyZVJlcXVlc3QSLAoEbmFtZRgBIAEoCUIe4EEC+kEYChZtZW1vcy5hcGkudjEvTWVtb1NoYXJlIi4KFUdldE1lbW9CeVNoYXJlUmVxdWVzdBIVCghzaGFyZV9pZBgBIAEoCUID4EECKlAKClZpc2liaWxpdHkSGgoWVklTSUJJTElUWV9VTlNQRUNJRklFRBAAEgsKB1BSSVZBVEUQARINCglQUk9URUNURUQQAhIKCgZQVUJMSUMQAzLuEgoLTWVtb1NlcnZpY2USZQoKQ3JlYXRlTWVtbxIfLm1lbW9zLmFwaS52MS5DcmVhdGVNZW1vUmVxdWVzdBoSLm1lbW9zLmFwaS52MS5NZW1vIiLaQQRtZW1vgtPkkwIVOgRtZW1vIg0vYXBpL3YxL21lbW9zEmYKCUxpc3RNZW1vcxIeLm1lbW9zLmFwaS52MS5MaXN0TWVtb3NSZXF1ZXN0Gh8ubWVtb3MuYXBpLnYxLkxpc3RNZW1vc1Jlc3BvbnNlIhjaQQCC0+STAg8SDS9hcGkvdjEvbWVtb3MSYgoHR2V0TWVtbxIcLm1lbW9zLmFwaS52MS5HZXRNZW1vUmVxdWVzdBoSLm1lbW9zLmFwaS52MS5NZW1vIiXaQQRuYW1lgtPkkwIYEhYvYXBpL3YxL3tuYW1lPW1lbW9zLyp9En8KClVwZGF0ZU1lbW8SHy5tZW1vcy5hcGkudjEuVXBkYXRlTWVtb1JlcXVlc3QaEi5tZW1vcy5hcGkudjEuTWVtbyI82kEQbWVtbyx1cGRhdGVfbWFza4LT5JMCIzoEbWVtbzIbL2FwaS92MS97bWVtby5uYW1lPW1lbW9zLyp9EmwKCkRlbGV0ZU1lbW8SHy5tZW1vcy5hcGkudjEuRGVsZXRlTWVtb1JlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiJdpBBG5hbWWC0+STAhgqFi9hcGkvdjEve25hbWU9bWVtb3MvKn0SiwEKElNldE1lbW9BdHRhY2htZW50cxInLm1lbW9zLmFwaS52MS5TZXRNZW1vQXR0YWNobWVudHNSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IjTaQQRuYW1lgtPkkwInOgEqMiIvYXBpL3YxL3tuYW1lPW1lbW9zLyp9L2F0dGFjaG1lbnRzEp0BChNMaXN0TWVtb0F0dGFjaG1lbnRzEigubWVtb3MuYXBpLnYxLkxpc3RNZW1vQXR0YWNobWVudHNSZXF1ZXN0GikubWVtb3MuYXBpLnYxLkxpc3RNZW1vQXR0YWNobWVudHNSZXNwb25zZSIx2kEEbmFtZYLT5JMCJBIiL2FwaS92MS97bmFtZT1tZW1vcy8qfS9hdHRhY2htZW50cxKFAQoQU2V0TWVtb1JlbGF0aW9ucxIlLm1lbW9zLmFwaS52MS5TZXRNZW1vUmVsYXRpb25zUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIy2kEEbmFtZYLT5JMCJToBKjIgL2FwaS92MS97bmFtZT1tZW1vcy8qfS9yZWxhdGlvbnMSlQEKEUxpc3RNZW1vUmVsYXRpb25zEiYubWVtb3MuYXBpLnYxLkxpc3RNZW1vUmVsYXRpb25zUmVxdWVzdBonLm1lbW9zLmFwaS52MS5MaXN0TWVtb1JlbGF0aW9uc1Jlc3BvbnNlIi/aQQRuYW1lgtPkkwIiEiAvYXBpL3YxL3tuYW1lPW1lbW9zLyp9L3JlbGF0aW9ucxKQAQoRQ3JlYXRlTWVtb0NvbW1lbnQSJi5tZW1vcy5hcGkudjEuQ3JlYXRlTWVtb0NvbW1lbnRSZXF1ZXN0GhIubWVtb3MuYXBpLnYxLk1lbW8iP9pBDG5hbWUsY29tbWVudILT5JMCKjoHY29tbWVudCIfL2FwaS92MS97bmFtZT1tZW1vcy8qfS9jb21tZW50cxKRAQoQTGlzdE1lbW9Db21tZW50cxIlLm1lbW9zLmFwaS52MS5MaXN0TWVtb0NvbW1lbnRzUmVxdWVzdBomLm1lbW9zLmFwaS52MS5MaXN0TWVtb0NvbW1lbnRzUmVzcG9uc2UiLtpBBG5hbWWC0+STAiESHy9hcGkvdjEve25hbWU9bWVtb3MvKn0vY29tbWVudHMSlQEKEUxpc3RNZW1vUmVhY3Rpb25zEiYubWVtb3MuYXBpLnYxLkxpc3RNZW1vUmVhY3Rpb25zUmVxdWVzdBonLm1lbW9zLmFwaS52MS5MaXN0TWVtb1JlYWN0aW9uc1Jlc3BvbnNlIi/aQQRuYW1lgtPkkwIiEiAvYXBpL3YxL3tuYW1lPW1lbW9zLyp9L3JlYWN0aW9ucxKJAQoSVXBzZXJ0TWVtb1JlYWN0aW9uEicubWVtb3MuYXBpLnYxLlVwc2VydE1lbW9SZWFjdGlvblJlcXVlc3QaFi5tZW1vcy5hcGkudjEuUmVhY3Rpb24iMtpBBG5hbWWC0+STAiU6ASoiIC9hcGkvdjEve25hbWU9bWVtb3MvKn0vcmVhY3Rpb25zEogBChJEZWxldGVNZW1vUmVhY3Rpb24SJy5tZW1vcy5hcGkudjEuRGVsZXRlTWVtb1JlYWN0aW9uUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIx2kEEbmFtZYLT5JMCJCoiL2FwaS92MS97bmFtZT1tZW1vcy8qL3JlYWN0aW9ucy8qfRKZAQoPQ3JlYXRlTWVtb1NoYXJlEiQubWVtb3MuYXBpLnYxLkNyZWF0ZU1lbW9TaGFyZVJlcXVlc3QaFy5tZW1vcy5hcGkudjEuTWVtb1NoYXJlIkfaQRFwYXJlbnQsbWVtb19zaGFyZYLT5JMCLToKbWVtb19zaGFyZSIfL2FwaS92MS97cGFyZW50PW1lbW9zLyp9L3NoYXJlcxKNAQoOTGlzdE1lbW9TaGFyZXMSIy5tZW1vcy5hcGkudjEuTGlzdE1lbW9TaGFyZXNSZXF1ZXN0GiQubWVtb3MuYXBpLnYxLkxpc3RNZW1vU2hhcmVzUmVzcG9uc2UiMNpBBnBhcmVudILT5JMCIRIfL2FwaS92MS97cGFyZW50PW1lbW9zLyp9L3NoYXJlcxJ/Cg9EZWxldGVNZW1vU2hhcmUSJC5tZW1vcy5hcGkudjEuRGVsZXRlTWVtb1NoYXJlUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIu2kEEbmFtZYLT5JMCISofL2FwaS92MS97bmFtZT1tZW1vcy8qL3NoYXJlcy8qfRJsCg5HZXRNZW1vQnlTaGFyZRIjLm1lbW9zLmFwaS52MS5HZXRNZW1vQnlTaGFyZVJlcXVlc3QaEi5tZW1vcy5hcGkudjEuTWVtbyIhgtPkkwIbEhkvYXBpL3YxL3NoYXJlcy97c2hhcmVfaWR9QqgBChBjb20ubWVtb3MuYXBpLnYxQhBNZW1vU2VydmljZVByb3RvUAFaMGdpdGh1Yi5jb20vdXNlbWVtb3MvbWVtb3MvcHJvdG8vZ2VuL2FwaS92MTthcGl2MaICA01BWKoCDE1lbW9zLkFwaS5WMcoCDE1lbW9zXEFwaVxWMeICGE1lbW9zXEFwaVxWMVxHUEJNZXRhZGF0YeoCDk1lbW9zOjpBcGk6OlYxYgZwcm90bzM", [file_api_v1_attachment_service, file_api_v1_common, file_google_api_annotations, file_google_api_client, file_google_api_field_behavior, file_google_api_resource, file_google_protobuf_empty, file_google_protobuf_field_mask, file_google_protobuf_timestamp]); /** * @generated from message memos.api.v1.Reaction @@ -960,6 +960,148 @@ export type DeleteMemoReactionRequest = Message<"memos.api.v1.DeleteMemoReaction export const DeleteMemoReactionRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_api_v1_memo_service, 22); +/** + * MemoShare is an access grant that permits read-only access to a memo via an opaque bearer token. + * + * @generated from message memos.api.v1.MemoShare + */ +export type MemoShare = Message<"memos.api.v1.MemoShare"> & { + /** + * The resource name of the share. Format: memos/{memo}/shares/{share} + * The {share} segment is the opaque token used in the share URL. + * + * @generated from field: string name = 1; + */ + name: string; + + /** + * Output only. When this share link was created. + * + * @generated from field: google.protobuf.Timestamp create_time = 2; + */ + createTime?: Timestamp; + + /** + * Optional. When set, the share link stops working after this time. + * If unset, the link never expires. + * + * @generated from field: optional google.protobuf.Timestamp expire_time = 3; + */ + expireTime?: Timestamp; +}; + +/** + * Describes the message memos.api.v1.MemoShare. + * Use `create(MemoShareSchema)` to create a new message. + */ +export const MemoShareSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_v1_memo_service, 23); + +/** + * @generated from message memos.api.v1.CreateMemoShareRequest + */ +export type CreateMemoShareRequest = Message<"memos.api.v1.CreateMemoShareRequest"> & { + /** + * Required. The resource name of the memo to share. + * Format: memos/{memo} + * + * @generated from field: string parent = 1; + */ + parent: string; + + /** + * Required. The share to create. + * + * @generated from field: memos.api.v1.MemoShare memo_share = 2; + */ + memoShare?: MemoShare; +}; + +/** + * Describes the message memos.api.v1.CreateMemoShareRequest. + * Use `create(CreateMemoShareRequestSchema)` to create a new message. + */ +export const CreateMemoShareRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_v1_memo_service, 24); + +/** + * @generated from message memos.api.v1.ListMemoSharesRequest + */ +export type ListMemoSharesRequest = Message<"memos.api.v1.ListMemoSharesRequest"> & { + /** + * Required. The resource name of the memo. + * Format: memos/{memo} + * + * @generated from field: string parent = 1; + */ + parent: string; +}; + +/** + * Describes the message memos.api.v1.ListMemoSharesRequest. + * Use `create(ListMemoSharesRequestSchema)` to create a new message. + */ +export const ListMemoSharesRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_v1_memo_service, 25); + +/** + * @generated from message memos.api.v1.ListMemoSharesResponse + */ +export type ListMemoSharesResponse = Message<"memos.api.v1.ListMemoSharesResponse"> & { + /** + * The list of share links. + * + * @generated from field: repeated memos.api.v1.MemoShare memo_shares = 1; + */ + memoShares: MemoShare[]; +}; + +/** + * Describes the message memos.api.v1.ListMemoSharesResponse. + * Use `create(ListMemoSharesResponseSchema)` to create a new message. + */ +export const ListMemoSharesResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_v1_memo_service, 26); + +/** + * @generated from message memos.api.v1.DeleteMemoShareRequest + */ +export type DeleteMemoShareRequest = Message<"memos.api.v1.DeleteMemoShareRequest"> & { + /** + * Required. The resource name of the share to delete. + * Format: memos/{memo}/shares/{share} + * + * @generated from field: string name = 1; + */ + name: string; +}; + +/** + * Describes the message memos.api.v1.DeleteMemoShareRequest. + * Use `create(DeleteMemoShareRequestSchema)` to create a new message. + */ +export const DeleteMemoShareRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_v1_memo_service, 27); + +/** + * @generated from message memos.api.v1.GetMemoByShareRequest + */ +export type GetMemoByShareRequest = Message<"memos.api.v1.GetMemoByShareRequest"> & { + /** + * Required. The share token extracted from the share URL (/s/{share_id}). + * + * @generated from field: string share_id = 1; + */ + shareId: string; +}; + +/** + * Describes the message memos.api.v1.GetMemoByShareRequest. + * Use `create(GetMemoByShareRequestSchema)` to create a new message. + */ +export const GetMemoByShareRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_v1_memo_service, 28); + /** * @generated from enum memos.api.v1.Visibility */ @@ -1135,6 +1277,47 @@ export const MemoService: GenService<{ input: typeof DeleteMemoReactionRequestSchema; output: typeof EmptySchema; }, + /** + * CreateMemoShare creates a share link for a memo. Requires authentication as the memo creator. + * + * @generated from rpc memos.api.v1.MemoService.CreateMemoShare + */ + createMemoShare: { + methodKind: "unary"; + input: typeof CreateMemoShareRequestSchema; + output: typeof MemoShareSchema; + }, + /** + * ListMemoShares lists all share links for a memo. Requires authentication as the memo creator. + * + * @generated from rpc memos.api.v1.MemoService.ListMemoShares + */ + listMemoShares: { + methodKind: "unary"; + input: typeof ListMemoSharesRequestSchema; + output: typeof ListMemoSharesResponseSchema; + }, + /** + * DeleteMemoShare revokes a share link. Requires authentication as the memo creator. + * + * @generated from rpc memos.api.v1.MemoService.DeleteMemoShare + */ + deleteMemoShare: { + methodKind: "unary"; + input: typeof DeleteMemoShareRequestSchema; + output: typeof EmptySchema; + }, + /** + * GetMemoByShare resolves a share token to its memo. No authentication required. + * Returns NOT_FOUND if the token is invalid or expired. + * + * @generated from rpc memos.api.v1.MemoService.GetMemoByShare + */ + getMemoByShare: { + methodKind: "unary"; + input: typeof GetMemoByShareRequestSchema; + output: typeof MemoSchema; + }, }> = /*@__PURE__*/ serviceDesc(file_api_v1_memo_service, 0); diff --git a/web/src/utils/auth-redirect.ts b/web/src/utils/auth-redirect.ts index 7f53c4111..030767c75 100644 --- a/web/src/utils/auth-redirect.ts +++ b/web/src/utils/auth-redirect.ts @@ -4,6 +4,7 @@ import { ROUTES } from "@/router/routes"; const PUBLIC_ROUTES = [ ROUTES.AUTH, // Authentication pages ROUTES.EXPLORE, // Explore page + ROUTES.SHARED_MEMO + "/", // Shared memo pages (share-link viewer) "/u/", // User profile pages (dynamic) "/memos/", // Individual memo detail pages (dynamic) ] as const;