Unverified Commit 3f3133d6 authored by memoclaw's avatar memoclaw Committed by GitHub

feat(memo): add share links for private memos (#5742)

Co-authored-by: 's avatarmemoclaw <265580040+memoclaw@users.noreply.github.com>
parent e1645177
......@@ -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];
}
This diff is collapsed.
This diff is collapsed.
......@@ -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",
......
......@@ -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:
......
......@@ -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).
......
......@@ -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) {
......
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
}
......@@ -17,6 +17,7 @@ const (
InstanceSettingNamePrefix = "instance/settings/"
UserNamePrefix = "users/"
MemoNamePrefix = "memos/"
MemoShareNamePrefix = "shares/"
AttachmentNamePrefix = "attachments/"
ReactionNamePrefix = "reactions/"
InboxNamePrefix = "inboxes/"
......
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)
}
......@@ -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
}
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()
}
}
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
}
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
}
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
}
......@@ -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
}
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)
}
-- 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);
......@@ -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`);
-- 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);
......@@ -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);
-- 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);
......@@ -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);
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 (
<aside className={cn("relative w-full h-auto max-h-screen overflow-auto flex flex-col gap-5", className)}>
{canManageShares && (
<div className="w-full space-y-2">
<SectionLabel>{t("memo-share.section-label")}</SectionLabel>
<Button variant="outline" className="w-full justify-start gap-2" onClick={() => setSharePanelOpen(true)}>
<Share2Icon className="w-4 h-4" />
{t("memo-share.open-panel")}
</Button>
</div>
)}
{hasReferenceRelations && (
<div className="w-full space-y-2">
<div className="flex items-center gap-1.5">
......@@ -94,6 +112,8 @@ const MemoDetailSidebar = ({ memo, className, parentPage }: Props) => {
</div>
</div>
)}
{sharePanelOpen && <MemoSharePanel memoName={memo.name} open={sharePanelOpen} onClose={() => setSharePanelOpen(false)} />}
</aside>
);
};
......
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<typeof useTranslate>): 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 (
<div className="flex flex-col gap-1 rounded-md border border-border p-3">
<div className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-xs text-muted-foreground">{url}</span>
<div className="flex shrink-0 items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleCopy} title={t("memo-share.copy")}>
{copied ? <CheckIcon className="h-3.5 w-3.5 text-green-500" /> : <CopyIcon className="h-3.5 w-3.5" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
onClick={handleRevoke}
disabled={deleteShare.isPending}
title={t("memo-share.revoke")}
>
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">{formatExpiry(share, t)}</p>
</div>
);
}
interface MemoSharePanelProps {
open: boolean;
onClose: () => void;
memoName: string;
}
const MemoSharePanel = ({ open, onClose, memoName }: MemoSharePanelProps) => {
const t = useTranslate();
const [expiry, setExpiry] = useState<ExpiryOption>("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 (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LinkIcon className="h-4 w-4" />
{t("memo-share.title")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
{/* Active links */}
<div className="flex flex-col gap-2">
<p className="text-sm font-medium text-muted-foreground">{t("memo-share.active-links")}</p>
{isLoading ? (
<Loader2Icon className="h-4 w-4 animate-spin text-muted-foreground" />
) : shares.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("memo-share.no-links")}</p>
) : (
<div className="flex flex-col gap-2">
{shares.map((share) => (
<ShareLinkRow key={share.name} share={share} memoName={memoName} />
))}
</div>
)}
</div>
{/* Create new link */}
<div className="flex items-center gap-2">
<Select value={expiry} onValueChange={(v) => setExpiry(v as ExpiryOption)}>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="never">{t("memo-share.expiry-never")}</SelectItem>
<SelectItem value="1d">{t("memo-share.expiry-1-day")}</SelectItem>
<SelectItem value="7d">{t("memo-share.expiry-7-days")}</SelectItem>
<SelectItem value="30d">{t("memo-share.expiry-30-days")}</SelectItem>
</SelectContent>
</Select>
<Button onClick={handleCreate} disabled={createShare.isPending} className="flex-1">
{createShare.isPending ? (
<>
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
{t("memo-share.creating")}
</>
) : (
t("memo-share.create-link")
)}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};
export default MemoSharePanel;
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() ?? "";
}
......@@ -51,11 +51,17 @@ const LazyImportPlugin: BackendModule = {
read: function (language, _, callback) {
const matchedLanguage = findNearestMatchedLanguage(language);
import(`./locales/${matchedLanguage}.json`)
.then((translation: Record<string, unknown>) => {
callback(null, translation);
.then((translationModule: Record<string, unknown>) => {
callback(null, (translationModule.default as Record<string, unknown>) ?? translationModule);
})
.catch(() => {
// Fallback to English.
import("./locales/en.json")
.then((translationModule: Record<string, unknown>) => {
callback(null, (translationModule.default as Record<string, unknown>) ?? translationModule);
})
.catch((error: unknown) => {
callback(error as Error, false);
});
});
},
};
......@@ -67,6 +73,9 @@ i18n
detection: {
order: ["navigator"],
},
interpolation: {
escapeValue: false,
},
fallbackLng: {
...fallbacks,
...{ default: ["en"] },
......
......@@ -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}}"
}
}
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 (
<div className="flex h-screen items-center justify-center">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
if (isNotFound || (!isLoading && !memo)) {
return (
<div className="flex h-screen flex-col items-center justify-center gap-3 text-center">
<AlertCircleIcon className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">{t("memo-share.invalid-link")}</p>
</div>
);
}
if (error || !memo) return null;
const displayDate = (memo.displayTime as Timestamp | undefined)
? timestampDate(memo.displayTime as Timestamp)?.toLocaleString(i18n.language)
: null;
return (
<div className="mx-auto w-full min-w-80 max-w-2xl px-4 py-8">
{/* Creator + date above the card */}
<div className="mb-3 flex flex-row items-center justify-between">
<div className="flex flex-row items-center gap-2">
<UserAvatar className="shrink-0" avatarUrl={creator?.avatarUrl} />
<span className="text-sm text-muted-foreground">{creator?.displayName || creator?.username || memo.creator}</span>
</div>
{displayDate && <span className="text-xs text-muted-foreground">{displayDate}</span>}
</div>
<div className="relative flex flex-col items-start gap-2 rounded-lg border border-border bg-card px-4 py-3 text-card-foreground">
<MemoContent content={memo.content} />
{memo.attachments.length > 0 && <AttachmentList attachments={withShareAttachmentLinks(memo.attachments, token)} />}
</div>
</div>
);
};
export default SharedMemo;
......@@ -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: <NotFound /> },
],
},
// Public share-link viewer — outside RootLayout to bypass auth-gating
// (including when disallowPublicVisibility is enabled on the instance)
{ path: "memos/shares/:token", element: <SharedMemo /> },
],
},
]);
......
......@@ -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;
......
......@@ -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;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment