feat: add link metadata endpoints

This commit is contained in:
boojack
2026-04-29 22:38:08 +08:00
parent c7242324a1
commit 9c5c604944
14 changed files with 1154 additions and 189 deletions
+5 -3
View File
@@ -29,9 +29,11 @@ var PublicMethods = map[string]struct{}{
"/memos.api.v1.IdentityProviderService/ListIdentityProviders": {},
// Memo Service - public memos (visibility filtering done in service layer)
"/memos.api.v1.MemoService/GetMemo": {},
"/memos.api.v1.MemoService/ListMemos": {},
"/memos.api.v1.MemoService/ListMemoComments": {},
"/memos.api.v1.MemoService/GetMemo": {},
"/memos.api.v1.MemoService/ListMemos": {},
"/memos.api.v1.MemoService/ListMemoComments": {},
"/memos.api.v1.MemoService/GetLinkMetadata": {},
"/memos.api.v1.MemoService/BatchGetLinkMetadata": {},
// Memo sharing - share-token endpoints require no authentication
"/memos.api.v1.MemoService/GetMemoByShare": {},
+2
View File
@@ -27,6 +27,8 @@ func TestPublicMethodsArePublic(t *testing.T) {
// Memo Service
"/memos.api.v1.MemoService/GetMemo",
"/memos.api.v1.MemoService/ListMemos",
"/memos.api.v1.MemoService/GetLinkMetadata",
"/memos.api.v1.MemoService/BatchGetLinkMetadata",
}
for _, method := range publicMethods {
+16
View File
@@ -415,6 +415,22 @@ func (s *ConnectServiceHandler) GetMemoByShare(ctx context.Context, req *connect
return connect.NewResponse(resp), nil
}
func (s *ConnectServiceHandler) GetLinkMetadata(ctx context.Context, req *connect.Request[v1pb.GetLinkMetadataRequest]) (*connect.Response[v1pb.LinkMetadata], error) {
resp, err := s.APIV1Service.GetLinkMetadata(ctx, req.Msg)
if err != nil {
return nil, convertGRPCError(err)
}
return connect.NewResponse(resp), nil
}
func (s *ConnectServiceHandler) BatchGetLinkMetadata(ctx context.Context, req *connect.Request[v1pb.BatchGetLinkMetadataRequest]) (*connect.Response[v1pb.BatchGetLinkMetadataResponse], error) {
resp, err := s.APIV1Service.BatchGetLinkMetadata(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) {
+103
View File
@@ -0,0 +1,103 @@
package v1
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/usememos/memos/internal/httpgetter"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
)
func TestGetLinkMetadata(t *testing.T) {
originalFetchHTMLMeta := fetchHTMLMeta
t.Cleanup(func() {
fetchHTMLMeta = originalFetchHTMLMeta
})
fetchHTMLMeta = func(url string) (*httpgetter.HTMLMeta, error) {
require.Equal(t, "https://example.com/article", url)
return &httpgetter.HTMLMeta{
Title: "Example title",
Description: "Example description",
Image: "https://example.com/cover.png",
}, nil
}
metadata, err := (&APIV1Service{}).GetLinkMetadata(context.Background(), &v1pb.GetLinkMetadataRequest{
Url: "https://example.com/article",
})
require.NoError(t, err)
require.Equal(t, "https://example.com/article", metadata.Url)
require.Equal(t, "Example title", metadata.Title)
require.Equal(t, "Example description", metadata.Description)
require.Equal(t, "https://example.com/cover.png", metadata.Image)
}
func TestGetLinkMetadataEmptyURL(t *testing.T) {
_, err := (&APIV1Service{}).GetLinkMetadata(context.Background(), &v1pb.GetLinkMetadataRequest{})
require.Error(t, err)
require.Equal(t, codes.InvalidArgument, status.Code(err))
}
func TestGetLinkMetadataInternalURL(t *testing.T) {
_, err := (&APIV1Service{}).GetLinkMetadata(context.Background(), &v1pb.GetLinkMetadataRequest{
Url: "http://192.168.0.1",
})
require.Error(t, err)
require.Equal(t, codes.InvalidArgument, status.Code(err))
}
func TestBatchGetLinkMetadata(t *testing.T) {
originalFetchHTMLMeta := fetchHTMLMeta
t.Cleanup(func() {
fetchHTMLMeta = originalFetchHTMLMeta
})
var fetchedURLs []string
fetchHTMLMeta = func(url string) (*httpgetter.HTMLMeta, error) {
fetchedURLs = append(fetchedURLs, url)
return &httpgetter.HTMLMeta{
Title: fmt.Sprintf("Title for %s", url),
Description: fmt.Sprintf("Description for %s", url),
Image: fmt.Sprintf("%s/cover.png", url),
}, nil
}
response, err := (&APIV1Service{}).BatchGetLinkMetadata(context.Background(), &v1pb.BatchGetLinkMetadataRequest{
Urls: []string{
"https://example.com/one",
"https://example.com/two",
},
})
require.NoError(t, err)
require.Equal(t, []string{"https://example.com/one", "https://example.com/two"}, fetchedURLs)
require.Len(t, response.LinkMetadata, 2)
require.Equal(t, "https://example.com/one", response.LinkMetadata[0].Url)
require.Equal(t, "Title for https://example.com/one", response.LinkMetadata[0].Title)
require.Equal(t, "https://example.com/two", response.LinkMetadata[1].Url)
require.Equal(t, "Title for https://example.com/two", response.LinkMetadata[1].Title)
}
func TestBatchGetLinkMetadataEmptyURLs(t *testing.T) {
_, err := (&APIV1Service{}).BatchGetLinkMetadata(context.Background(), &v1pb.BatchGetLinkMetadataRequest{})
require.Error(t, err)
require.Equal(t, codes.InvalidArgument, status.Code(err))
}
func TestBatchGetLinkMetadataTooManyURLs(t *testing.T) {
urls := make([]string, maxBatchGetLinkMetadata+1)
for i := range urls {
urls[i] = fmt.Sprintf("https://example.com/%d", i)
}
_, err := (&APIV1Service{}).BatchGetLinkMetadata(context.Background(), &v1pb.BatchGetLinkMetadataRequest{
Urls: urls,
})
require.Error(t, err)
require.Equal(t, codes.InvalidArgument, status.Code(err))
}
+51
View File
@@ -13,6 +13,7 @@ import (
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"github.com/usememos/memos/internal/httpgetter"
"github.com/usememos/memos/internal/webhook"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
@@ -24,6 +25,10 @@ import (
// CreateMemo when it is called internally (e.g., from CreateMemoComment).
type suppressSSEKey struct{}
const maxBatchGetLinkMetadata = 10
var fetchHTMLMeta = httpgetter.GetHTMLMeta
func withSuppressSSE(ctx context.Context) context.Context {
return context.WithValue(ctx, suppressSSEKey{}, true)
}
@@ -382,6 +387,52 @@ func (s *APIV1Service) GetMemo(ctx context.Context, request *v1pb.GetMemoRequest
return memoMessage, nil
}
// GetLinkMetadata gets metadata for a link.
func (*APIV1Service) GetLinkMetadata(_ context.Context, request *v1pb.GetLinkMetadataRequest) (*v1pb.LinkMetadata, error) {
return getLinkMetadata(request.GetUrl())
}
// BatchGetLinkMetadata gets metadata for links.
func (*APIV1Service) BatchGetLinkMetadata(_ context.Context, request *v1pb.BatchGetLinkMetadataRequest) (*v1pb.BatchGetLinkMetadataResponse, error) {
if len(request.Urls) == 0 {
return nil, status.Errorf(codes.InvalidArgument, "urls are required")
}
if len(request.Urls) > maxBatchGetLinkMetadata {
return nil, status.Errorf(codes.InvalidArgument, "too many urls (max %d)", maxBatchGetLinkMetadata)
}
linkMetadata := make([]*v1pb.LinkMetadata, 0, len(request.Urls))
for _, url := range request.Urls {
metadata, err := getLinkMetadata(url)
if err != nil {
return nil, err
}
linkMetadata = append(linkMetadata, metadata)
}
return &v1pb.BatchGetLinkMetadataResponse{
LinkMetadata: linkMetadata,
}, nil
}
func getLinkMetadata(inputURL string) (*v1pb.LinkMetadata, error) {
url := strings.TrimSpace(inputURL)
if url == "" {
return nil, status.Errorf(codes.InvalidArgument, "url is required")
}
htmlMeta, err := fetchHTMLMeta(url)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "failed to fetch link metadata: %v", err)
}
return &v1pb.LinkMetadata{
Url: inputURL,
Title: htmlMeta.Title,
Description: htmlMeta.Description,
Image: htmlMeta.Image,
}, nil
}
func (s *APIV1Service) UpdateMemo(ctx context.Context, request *v1pb.UpdateMemoRequest) (*v1pb.Memo, error) {
memoUID, err := ExtractMemoUIDFromName(request.Memo.Name)
if err != nil {