refactor: migrate binary file serving from gRPC to dedicated HTTP fileserver

Migrates attachment and avatar binary serving from gRPC endpoints to a new dedicated HTTP fileserver package, fixing Safari video playback issues and improving architectural separation.

Key changes:
- Created server/router/fileserver package for all binary file serving
- Removed GetAttachmentBinary and GetUserAvatar gRPC endpoints from proto
- Implemented native HTTP handlers with full range request support
- Added authentication support (session cookies + JWT) to fileserver
- New avatar endpoint supports lookup by user ID or username
- Eliminated duplicate auth constants (imports from api/v1)

HTTP endpoints:
- Attachments: /file/attachments/:uid/:filename (unchanged URL)
- Avatars: /file/users/:identifier/avatar (new URL format)

This fixes Safari video/audio playback by using http.ServeContent() which properly handles HTTP 206 Partial Content responses and range request headers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Steven 2025-12-09 08:46:55 +08:00
parent 9ea27ee61f
commit 1cf047707b
18 changed files with 1170 additions and 1757 deletions

View File

@ -5,7 +5,6 @@ package memos.api.v1;
import "google/api/annotations.proto";
import "google/api/client.proto";
import "google/api/field_behavior.proto";
import "google/api/httpbody.proto";
import "google/api/resource.proto";
import "google/protobuf/empty.proto";
import "google/protobuf/field_mask.proto";
@ -31,11 +30,6 @@ service AttachmentService {
option (google.api.http) = {get: "/api/v1/{name=attachments/*}"};
option (google.api.method_signature) = "name";
}
// GetAttachmentBinary returns a attachment binary by name.
rpc GetAttachmentBinary(GetAttachmentBinaryRequest) returns (google.api.HttpBody) {
option (google.api.http) = {get: "/file/{name=attachments/*}/{filename}"};
option (google.api.method_signature) = "name,filename,thumbnail";
}
// UpdateAttachment updates a attachment.
rpc UpdateAttachment(UpdateAttachmentRequest) returns (Attachment) {
option (google.api.http) = {
@ -138,21 +132,6 @@ message GetAttachmentRequest {
];
}
message GetAttachmentBinaryRequest {
// Required. The attachment name of the attachment.
// Format: attachments/{attachment}
string name = 1 [
(google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {type: "memos.api.v1/Attachment"}
];
// The filename of the attachment. Mainly used for downloading.
string filename = 2 [(google.api.field_behavior) = REQUIRED];
// Optional. A flag indicating if the thumbnail version of the attachment should be returned.
bool thumbnail = 3 [(google.api.field_behavior) = OPTIONAL];
}
message UpdateAttachmentRequest {
// Required. The attachment which replaces the attachment on the server.
Attachment attachment = 1 [(google.api.field_behavior) = REQUIRED];

View File

@ -6,7 +6,6 @@ import "api/v1/common.proto";
import "google/api/annotations.proto";
import "google/api/client.proto";
import "google/api/field_behavior.proto";
import "google/api/httpbody.proto";
import "google/api/resource.proto";
import "google/protobuf/empty.proto";
import "google/protobuf/field_mask.proto";
@ -53,12 +52,6 @@ service UserService {
option (google.api.method_signature) = "name";
}
// GetUserAvatar gets the avatar of a user.
rpc GetUserAvatar(GetUserAvatarRequest) returns (google.api.HttpBody) {
option (google.api.http) = {get: "/api/v1/{name=users/*}/avatar"};
option (google.api.method_signature) = "name";
}
// ListAllUserStats returns statistics for all users.
rpc ListAllUserStats(ListAllUserStatsRequest) returns (ListAllUserStatsResponse) {
option (google.api.http) = {get: "/api/v1/users:stats"};
@ -324,15 +317,6 @@ message DeleteUserRequest {
bool force = 2 [(google.api.field_behavior) = OPTIONAL];
}
message GetUserAvatarRequest {
// Required. The resource name of the user.
// Format: users/{user}
string name = 1 [
(google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {type: "memos.api.v1/User"}
];
}
// User statistics messages
message UserStats {
option (google.api.resource) = {

View File

@ -8,7 +8,6 @@ package apiv1
import (
_ "google.golang.org/genproto/googleapis/api/annotations"
httpbody "google.golang.org/genproto/googleapis/api/httpbody"
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
emptypb "google.golang.org/protobuf/types/known/emptypb"
@ -381,70 +380,6 @@ func (x *GetAttachmentRequest) GetName() string {
return ""
}
type GetAttachmentBinaryRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Required. The attachment name of the attachment.
// Format: attachments/{attachment}
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
// The filename of the attachment. Mainly used for downloading.
Filename string `protobuf:"bytes,2,opt,name=filename,proto3" json:"filename,omitempty"`
// Optional. A flag indicating if the thumbnail version of the attachment should be returned.
Thumbnail bool `protobuf:"varint,3,opt,name=thumbnail,proto3" json:"thumbnail,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetAttachmentBinaryRequest) Reset() {
*x = GetAttachmentBinaryRequest{}
mi := &file_api_v1_attachment_service_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetAttachmentBinaryRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetAttachmentBinaryRequest) ProtoMessage() {}
func (x *GetAttachmentBinaryRequest) ProtoReflect() protoreflect.Message {
mi := &file_api_v1_attachment_service_proto_msgTypes[5]
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 GetAttachmentBinaryRequest.ProtoReflect.Descriptor instead.
func (*GetAttachmentBinaryRequest) Descriptor() ([]byte, []int) {
return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{5}
}
func (x *GetAttachmentBinaryRequest) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *GetAttachmentBinaryRequest) GetFilename() string {
if x != nil {
return x.Filename
}
return ""
}
func (x *GetAttachmentBinaryRequest) GetThumbnail() bool {
if x != nil {
return x.Thumbnail
}
return false
}
type UpdateAttachmentRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Required. The attachment which replaces the attachment on the server.
@ -457,7 +392,7 @@ type UpdateAttachmentRequest struct {
func (x *UpdateAttachmentRequest) Reset() {
*x = UpdateAttachmentRequest{}
mi := &file_api_v1_attachment_service_proto_msgTypes[6]
mi := &file_api_v1_attachment_service_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -469,7 +404,7 @@ func (x *UpdateAttachmentRequest) String() string {
func (*UpdateAttachmentRequest) ProtoMessage() {}
func (x *UpdateAttachmentRequest) ProtoReflect() protoreflect.Message {
mi := &file_api_v1_attachment_service_proto_msgTypes[6]
mi := &file_api_v1_attachment_service_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -482,7 +417,7 @@ func (x *UpdateAttachmentRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use UpdateAttachmentRequest.ProtoReflect.Descriptor instead.
func (*UpdateAttachmentRequest) Descriptor() ([]byte, []int) {
return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{6}
return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{5}
}
func (x *UpdateAttachmentRequest) GetAttachment() *Attachment {
@ -510,7 +445,7 @@ type DeleteAttachmentRequest struct {
func (x *DeleteAttachmentRequest) Reset() {
*x = DeleteAttachmentRequest{}
mi := &file_api_v1_attachment_service_proto_msgTypes[7]
mi := &file_api_v1_attachment_service_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -522,7 +457,7 @@ func (x *DeleteAttachmentRequest) String() string {
func (*DeleteAttachmentRequest) ProtoMessage() {}
func (x *DeleteAttachmentRequest) ProtoReflect() protoreflect.Message {
mi := &file_api_v1_attachment_service_proto_msgTypes[7]
mi := &file_api_v1_attachment_service_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -535,7 +470,7 @@ func (x *DeleteAttachmentRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use DeleteAttachmentRequest.ProtoReflect.Descriptor instead.
func (*DeleteAttachmentRequest) Descriptor() ([]byte, []int) {
return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{7}
return file_api_v1_attachment_service_proto_rawDescGZIP(), []int{6}
}
func (x *DeleteAttachmentRequest) GetName() string {
@ -549,7 +484,7 @@ var File_api_v1_attachment_service_proto protoreflect.FileDescriptor
const file_api_v1_attachment_service_proto_rawDesc = "" +
"\n" +
"\x1fapi/v1/attachment_service.proto\x12\fmemos.api.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/httpbody.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xfb\x02\n" +
"\x1fapi/v1/attachment_service.proto\x12\fmemos.api.v1\x1a\x1cgoogle/api/annotations.proto\x1a\x17google/api/client.proto\x1a\x1fgoogle/api/field_behavior.proto\x1a\x19google/api/resource.proto\x1a\x1bgoogle/protobuf/empty.proto\x1a google/protobuf/field_mask.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xfb\x02\n" +
"\n" +
"Attachment\x12\x17\n" +
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12@\n" +
@ -582,12 +517,7 @@ const file_api_v1_attachment_service_proto_rawDesc = "" +
"total_size\x18\x03 \x01(\x05R\ttotalSize\"K\n" +
"\x14GetAttachmentRequest\x123\n" +
"\x04name\x18\x01 \x01(\tB\x1f\xe0A\x02\xfaA\x19\n" +
"\x17memos.api.v1/AttachmentR\x04name\"\x95\x01\n" +
"\x1aGetAttachmentBinaryRequest\x123\n" +
"\x04name\x18\x01 \x01(\tB\x1f\xe0A\x02\xfaA\x19\n" +
"\x17memos.api.v1/AttachmentR\x04name\x12\x1f\n" +
"\bfilename\x18\x02 \x01(\tB\x03\xe0A\x02R\bfilename\x12!\n" +
"\tthumbnail\x18\x03 \x01(\bB\x03\xe0A\x01R\tthumbnail\"\x9a\x01\n" +
"\x17memos.api.v1/AttachmentR\x04name\"\x9a\x01\n" +
"\x17UpdateAttachmentRequest\x12=\n" +
"\n" +
"attachment\x18\x01 \x01(\v2\x18.memos.api.v1.AttachmentB\x03\xe0A\x02R\n" +
@ -596,14 +526,13 @@ const file_api_v1_attachment_service_proto_rawDesc = "" +
"updateMask\"N\n" +
"\x17DeleteAttachmentRequest\x123\n" +
"\x04name\x18\x01 \x01(\tB\x1f\xe0A\x02\xfaA\x19\n" +
"\x17memos.api.v1/AttachmentR\x04name2\xe5\x06\n" +
"\x17memos.api.v1/AttachmentR\x04name2\xc4\x05\n" +
"\x11AttachmentService\x12\x89\x01\n" +
"\x10CreateAttachment\x12%.memos.api.v1.CreateAttachmentRequest\x1a\x18.memos.api.v1.Attachment\"4\xdaA\n" +
"attachment\x82\xd3\xe4\x93\x02!:\n" +
"attachment\"\x13/api/v1/attachments\x12{\n" +
"\x0fListAttachments\x12$.memos.api.v1.ListAttachmentsRequest\x1a%.memos.api.v1.ListAttachmentsResponse\"\x1b\x82\xd3\xe4\x93\x02\x15\x12\x13/api/v1/attachments\x12z\n" +
"\rGetAttachment\x12\".memos.api.v1.GetAttachmentRequest\x1a\x18.memos.api.v1.Attachment\"+\xdaA\x04name\x82\xd3\xe4\x93\x02\x1e\x12\x1c/api/v1/{name=attachments/*}\x12\x9e\x01\n" +
"\x13GetAttachmentBinary\x12(.memos.api.v1.GetAttachmentBinaryRequest\x1a\x14.google.api.HttpBody\"G\xdaA\x17name,filename,thumbnail\x82\xd3\xe4\x93\x02'\x12%/file/{name=attachments/*}/{filename}\x12\xa9\x01\n" +
"\rGetAttachment\x12\".memos.api.v1.GetAttachmentRequest\x1a\x18.memos.api.v1.Attachment\"+\xdaA\x04name\x82\xd3\xe4\x93\x02\x1e\x12\x1c/api/v1/{name=attachments/*}\x12\xa9\x01\n" +
"\x10UpdateAttachment\x12%.memos.api.v1.UpdateAttachmentRequest\x1a\x18.memos.api.v1.Attachment\"T\xdaA\x16attachment,update_mask\x82\xd3\xe4\x93\x025:\n" +
"attachment2'/api/v1/{attachment.name=attachments/*}\x12~\n" +
"\x10DeleteAttachment\x12%.memos.api.v1.DeleteAttachmentRequest\x1a\x16.google.protobuf.Empty\"+\xdaA\x04name\x82\xd3\xe4\x93\x02\x1e*\x1c/api/v1/{name=attachments/*}B\xae\x01\n" +
@ -621,41 +550,37 @@ func file_api_v1_attachment_service_proto_rawDescGZIP() []byte {
return file_api_v1_attachment_service_proto_rawDescData
}
var file_api_v1_attachment_service_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
var file_api_v1_attachment_service_proto_msgTypes = make([]protoimpl.MessageInfo, 7)
var file_api_v1_attachment_service_proto_goTypes = []any{
(*Attachment)(nil), // 0: memos.api.v1.Attachment
(*CreateAttachmentRequest)(nil), // 1: memos.api.v1.CreateAttachmentRequest
(*ListAttachmentsRequest)(nil), // 2: memos.api.v1.ListAttachmentsRequest
(*ListAttachmentsResponse)(nil), // 3: memos.api.v1.ListAttachmentsResponse
(*GetAttachmentRequest)(nil), // 4: memos.api.v1.GetAttachmentRequest
(*GetAttachmentBinaryRequest)(nil), // 5: memos.api.v1.GetAttachmentBinaryRequest
(*UpdateAttachmentRequest)(nil), // 6: memos.api.v1.UpdateAttachmentRequest
(*DeleteAttachmentRequest)(nil), // 7: memos.api.v1.DeleteAttachmentRequest
(*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp
(*fieldmaskpb.FieldMask)(nil), // 9: google.protobuf.FieldMask
(*httpbody.HttpBody)(nil), // 10: google.api.HttpBody
(*emptypb.Empty)(nil), // 11: google.protobuf.Empty
(*Attachment)(nil), // 0: memos.api.v1.Attachment
(*CreateAttachmentRequest)(nil), // 1: memos.api.v1.CreateAttachmentRequest
(*ListAttachmentsRequest)(nil), // 2: memos.api.v1.ListAttachmentsRequest
(*ListAttachmentsResponse)(nil), // 3: memos.api.v1.ListAttachmentsResponse
(*GetAttachmentRequest)(nil), // 4: memos.api.v1.GetAttachmentRequest
(*UpdateAttachmentRequest)(nil), // 5: memos.api.v1.UpdateAttachmentRequest
(*DeleteAttachmentRequest)(nil), // 6: memos.api.v1.DeleteAttachmentRequest
(*timestamppb.Timestamp)(nil), // 7: google.protobuf.Timestamp
(*fieldmaskpb.FieldMask)(nil), // 8: google.protobuf.FieldMask
(*emptypb.Empty)(nil), // 9: google.protobuf.Empty
}
var file_api_v1_attachment_service_proto_depIdxs = []int32{
8, // 0: memos.api.v1.Attachment.create_time:type_name -> google.protobuf.Timestamp
7, // 0: memos.api.v1.Attachment.create_time:type_name -> google.protobuf.Timestamp
0, // 1: memos.api.v1.CreateAttachmentRequest.attachment:type_name -> memos.api.v1.Attachment
0, // 2: memos.api.v1.ListAttachmentsResponse.attachments:type_name -> memos.api.v1.Attachment
0, // 3: memos.api.v1.UpdateAttachmentRequest.attachment:type_name -> memos.api.v1.Attachment
9, // 4: memos.api.v1.UpdateAttachmentRequest.update_mask:type_name -> google.protobuf.FieldMask
8, // 4: memos.api.v1.UpdateAttachmentRequest.update_mask:type_name -> google.protobuf.FieldMask
1, // 5: memos.api.v1.AttachmentService.CreateAttachment:input_type -> memos.api.v1.CreateAttachmentRequest
2, // 6: memos.api.v1.AttachmentService.ListAttachments:input_type -> memos.api.v1.ListAttachmentsRequest
4, // 7: memos.api.v1.AttachmentService.GetAttachment:input_type -> memos.api.v1.GetAttachmentRequest
5, // 8: memos.api.v1.AttachmentService.GetAttachmentBinary:input_type -> memos.api.v1.GetAttachmentBinaryRequest
6, // 9: memos.api.v1.AttachmentService.UpdateAttachment:input_type -> memos.api.v1.UpdateAttachmentRequest
7, // 10: memos.api.v1.AttachmentService.DeleteAttachment:input_type -> memos.api.v1.DeleteAttachmentRequest
0, // 11: memos.api.v1.AttachmentService.CreateAttachment:output_type -> memos.api.v1.Attachment
3, // 12: memos.api.v1.AttachmentService.ListAttachments:output_type -> memos.api.v1.ListAttachmentsResponse
0, // 13: memos.api.v1.AttachmentService.GetAttachment:output_type -> memos.api.v1.Attachment
10, // 14: memos.api.v1.AttachmentService.GetAttachmentBinary:output_type -> google.api.HttpBody
0, // 15: memos.api.v1.AttachmentService.UpdateAttachment:output_type -> memos.api.v1.Attachment
11, // 16: memos.api.v1.AttachmentService.DeleteAttachment:output_type -> google.protobuf.Empty
11, // [11:17] is the sub-list for method output_type
5, // [5:11] is the sub-list for method input_type
5, // 8: memos.api.v1.AttachmentService.UpdateAttachment:input_type -> memos.api.v1.UpdateAttachmentRequest
6, // 9: memos.api.v1.AttachmentService.DeleteAttachment:input_type -> memos.api.v1.DeleteAttachmentRequest
0, // 10: memos.api.v1.AttachmentService.CreateAttachment:output_type -> memos.api.v1.Attachment
3, // 11: memos.api.v1.AttachmentService.ListAttachments:output_type -> memos.api.v1.ListAttachmentsResponse
0, // 12: memos.api.v1.AttachmentService.GetAttachment:output_type -> memos.api.v1.Attachment
0, // 13: memos.api.v1.AttachmentService.UpdateAttachment:output_type -> memos.api.v1.Attachment
9, // 14: memos.api.v1.AttachmentService.DeleteAttachment:output_type -> google.protobuf.Empty
10, // [10:15] is the sub-list for method output_type
5, // [5:10] is the sub-list for method input_type
5, // [5:5] is the sub-list for extension type_name
5, // [5:5] is the sub-list for extension extendee
0, // [0:5] is the sub-list for field type_name
@ -673,7 +598,7 @@ func file_api_v1_attachment_service_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_v1_attachment_service_proto_rawDesc), len(file_api_v1_attachment_service_proto_rawDesc)),
NumEnums: 0,
NumMessages: 8,
NumMessages: 7,
NumExtensions: 0,
NumServices: 1,
},

View File

@ -150,75 +150,6 @@ func local_request_AttachmentService_GetAttachment_0(ctx context.Context, marsha
return msg, metadata, err
}
var filter_AttachmentService_GetAttachmentBinary_0 = &utilities.DoubleArray{Encoding: map[string]int{"name": 0, "filename": 1}, Base: []int{1, 1, 2, 0, 0}, Check: []int{0, 1, 1, 2, 3}}
func request_AttachmentService_GetAttachmentBinary_0(ctx context.Context, marshaler runtime.Marshaler, client AttachmentServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq GetAttachmentBinaryRequest
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)
}
val, ok = pathParams["filename"]
if !ok {
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "filename")
}
protoReq.Filename, err = runtime.String(val)
if err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "filename", err)
}
if err := req.ParseForm(); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_AttachmentService_GetAttachmentBinary_0); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := client.GetAttachmentBinary(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_AttachmentService_GetAttachmentBinary_0(ctx context.Context, marshaler runtime.Marshaler, server AttachmentServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq GetAttachmentBinaryRequest
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)
}
val, ok = pathParams["filename"]
if !ok {
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "filename")
}
protoReq.Filename, err = runtime.String(val)
if err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "filename", err)
}
if err := req.ParseForm(); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_AttachmentService_GetAttachmentBinary_0); err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
msg, err := server.GetAttachmentBinary(ctx, &protoReq)
return msg, metadata, err
}
var filter_AttachmentService_UpdateAttachment_0 = &utilities.DoubleArray{Encoding: map[string]int{"attachment": 0, "name": 1}, Base: []int{1, 2, 1, 0, 0}, Check: []int{0, 1, 2, 3, 2}}
func request_AttachmentService_UpdateAttachment_0(ctx context.Context, marshaler runtime.Marshaler, client AttachmentServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
@ -405,26 +336,6 @@ func RegisterAttachmentServiceHandlerServer(ctx context.Context, mux *runtime.Se
}
forward_AttachmentService_GetAttachment_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_AttachmentService_GetAttachmentBinary_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.AttachmentService/GetAttachmentBinary", runtime.WithHTTPPathPattern("/file/{name=attachments/*}/{filename}"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_AttachmentService_GetAttachmentBinary_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_AttachmentService_GetAttachmentBinary_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPatch, pattern_AttachmentService_UpdateAttachment_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@ -556,23 +467,6 @@ func RegisterAttachmentServiceHandlerClient(ctx context.Context, mux *runtime.Se
}
forward_AttachmentService_GetAttachment_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_AttachmentService_GetAttachmentBinary_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.AttachmentService/GetAttachmentBinary", runtime.WithHTTPPathPattern("/file/{name=attachments/*}/{filename}"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_AttachmentService_GetAttachmentBinary_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_AttachmentService_GetAttachmentBinary_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodPatch, pattern_AttachmentService_UpdateAttachment_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@ -611,19 +505,17 @@ func RegisterAttachmentServiceHandlerClient(ctx context.Context, mux *runtime.Se
}
var (
pattern_AttachmentService_CreateAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "attachments"}, ""))
pattern_AttachmentService_ListAttachments_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "attachments"}, ""))
pattern_AttachmentService_GetAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "name"}, ""))
pattern_AttachmentService_GetAttachmentBinary_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 2, 5, 2, 1, 0, 4, 1, 5, 3}, []string{"file", "attachments", "name", "filename"}, ""))
pattern_AttachmentService_UpdateAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "attachment.name"}, ""))
pattern_AttachmentService_DeleteAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "name"}, ""))
pattern_AttachmentService_CreateAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "attachments"}, ""))
pattern_AttachmentService_ListAttachments_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "attachments"}, ""))
pattern_AttachmentService_GetAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "name"}, ""))
pattern_AttachmentService_UpdateAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "attachment.name"}, ""))
pattern_AttachmentService_DeleteAttachment_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "attachments", "name"}, ""))
)
var (
forward_AttachmentService_CreateAttachment_0 = runtime.ForwardResponseMessage
forward_AttachmentService_ListAttachments_0 = runtime.ForwardResponseMessage
forward_AttachmentService_GetAttachment_0 = runtime.ForwardResponseMessage
forward_AttachmentService_GetAttachmentBinary_0 = runtime.ForwardResponseMessage
forward_AttachmentService_UpdateAttachment_0 = runtime.ForwardResponseMessage
forward_AttachmentService_DeleteAttachment_0 = runtime.ForwardResponseMessage
forward_AttachmentService_CreateAttachment_0 = runtime.ForwardResponseMessage
forward_AttachmentService_ListAttachments_0 = runtime.ForwardResponseMessage
forward_AttachmentService_GetAttachment_0 = runtime.ForwardResponseMessage
forward_AttachmentService_UpdateAttachment_0 = runtime.ForwardResponseMessage
forward_AttachmentService_DeleteAttachment_0 = runtime.ForwardResponseMessage
)

View File

@ -8,7 +8,6 @@ package apiv1
import (
context "context"
httpbody "google.golang.org/genproto/googleapis/api/httpbody"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
@ -21,12 +20,11 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
AttachmentService_CreateAttachment_FullMethodName = "/memos.api.v1.AttachmentService/CreateAttachment"
AttachmentService_ListAttachments_FullMethodName = "/memos.api.v1.AttachmentService/ListAttachments"
AttachmentService_GetAttachment_FullMethodName = "/memos.api.v1.AttachmentService/GetAttachment"
AttachmentService_GetAttachmentBinary_FullMethodName = "/memos.api.v1.AttachmentService/GetAttachmentBinary"
AttachmentService_UpdateAttachment_FullMethodName = "/memos.api.v1.AttachmentService/UpdateAttachment"
AttachmentService_DeleteAttachment_FullMethodName = "/memos.api.v1.AttachmentService/DeleteAttachment"
AttachmentService_CreateAttachment_FullMethodName = "/memos.api.v1.AttachmentService/CreateAttachment"
AttachmentService_ListAttachments_FullMethodName = "/memos.api.v1.AttachmentService/ListAttachments"
AttachmentService_GetAttachment_FullMethodName = "/memos.api.v1.AttachmentService/GetAttachment"
AttachmentService_UpdateAttachment_FullMethodName = "/memos.api.v1.AttachmentService/UpdateAttachment"
AttachmentService_DeleteAttachment_FullMethodName = "/memos.api.v1.AttachmentService/DeleteAttachment"
)
// AttachmentServiceClient is the client API for AttachmentService service.
@ -39,8 +37,6 @@ type AttachmentServiceClient interface {
ListAttachments(ctx context.Context, in *ListAttachmentsRequest, opts ...grpc.CallOption) (*ListAttachmentsResponse, error)
// GetAttachment returns a attachment by name.
GetAttachment(ctx context.Context, in *GetAttachmentRequest, opts ...grpc.CallOption) (*Attachment, error)
// GetAttachmentBinary returns a attachment binary by name.
GetAttachmentBinary(ctx context.Context, in *GetAttachmentBinaryRequest, opts ...grpc.CallOption) (*httpbody.HttpBody, error)
// UpdateAttachment updates a attachment.
UpdateAttachment(ctx context.Context, in *UpdateAttachmentRequest, opts ...grpc.CallOption) (*Attachment, error)
// DeleteAttachment deletes a attachment by name.
@ -85,16 +81,6 @@ func (c *attachmentServiceClient) GetAttachment(ctx context.Context, in *GetAtta
return out, nil
}
func (c *attachmentServiceClient) GetAttachmentBinary(ctx context.Context, in *GetAttachmentBinaryRequest, opts ...grpc.CallOption) (*httpbody.HttpBody, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(httpbody.HttpBody)
err := c.cc.Invoke(ctx, AttachmentService_GetAttachmentBinary_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *attachmentServiceClient) UpdateAttachment(ctx context.Context, in *UpdateAttachmentRequest, opts ...grpc.CallOption) (*Attachment, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(Attachment)
@ -125,8 +111,6 @@ type AttachmentServiceServer interface {
ListAttachments(context.Context, *ListAttachmentsRequest) (*ListAttachmentsResponse, error)
// GetAttachment returns a attachment by name.
GetAttachment(context.Context, *GetAttachmentRequest) (*Attachment, error)
// GetAttachmentBinary returns a attachment binary by name.
GetAttachmentBinary(context.Context, *GetAttachmentBinaryRequest) (*httpbody.HttpBody, error)
// UpdateAttachment updates a attachment.
UpdateAttachment(context.Context, *UpdateAttachmentRequest) (*Attachment, error)
// DeleteAttachment deletes a attachment by name.
@ -150,9 +134,6 @@ func (UnimplementedAttachmentServiceServer) ListAttachments(context.Context, *Li
func (UnimplementedAttachmentServiceServer) GetAttachment(context.Context, *GetAttachmentRequest) (*Attachment, error) {
return nil, status.Error(codes.Unimplemented, "method GetAttachment not implemented")
}
func (UnimplementedAttachmentServiceServer) GetAttachmentBinary(context.Context, *GetAttachmentBinaryRequest) (*httpbody.HttpBody, error) {
return nil, status.Error(codes.Unimplemented, "method GetAttachmentBinary not implemented")
}
func (UnimplementedAttachmentServiceServer) UpdateAttachment(context.Context, *UpdateAttachmentRequest) (*Attachment, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateAttachment not implemented")
}
@ -234,24 +215,6 @@ func _AttachmentService_GetAttachment_Handler(srv interface{}, ctx context.Conte
return interceptor(ctx, in, info, handler)
}
func _AttachmentService_GetAttachmentBinary_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetAttachmentBinaryRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AttachmentServiceServer).GetAttachmentBinary(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AttachmentService_GetAttachmentBinary_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AttachmentServiceServer).GetAttachmentBinary(ctx, req.(*GetAttachmentBinaryRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AttachmentService_UpdateAttachment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateAttachmentRequest)
if err := dec(in); err != nil {
@ -307,10 +270,6 @@ var AttachmentService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetAttachment",
Handler: _AttachmentService_GetAttachment_Handler,
},
{
MethodName: "GetAttachmentBinary",
Handler: _AttachmentService_GetAttachmentBinary_Handler,
},
{
MethodName: "UpdateAttachment",
Handler: _AttachmentService_UpdateAttachment_Handler,

File diff suppressed because it is too large Load Diff

View File

@ -298,45 +298,6 @@ func local_request_UserService_DeleteUser_0(ctx context.Context, marshaler runti
return msg, metadata, err
}
func request_UserService_GetUserAvatar_0(ctx context.Context, marshaler runtime.Marshaler, client UserServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq GetUserAvatarRequest
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.GetUserAvatar(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_UserService_GetUserAvatar_0(ctx context.Context, marshaler runtime.Marshaler, server UserServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq GetUserAvatarRequest
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.GetUserAvatar(ctx, &protoReq)
return msg, metadata, err
}
func request_UserService_ListAllUserStats_0(ctx context.Context, marshaler runtime.Marshaler, client UserServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq ListAllUserStatsRequest
@ -1282,26 +1243,6 @@ func RegisterUserServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux
}
forward_UserService_DeleteUser_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_UserService_GetUserAvatar_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.UserService/GetUserAvatar", runtime.WithHTTPPathPattern("/api/v1/{name=users/*}/avatar"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_UserService_GetUserAvatar_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_UserService_GetUserAvatar_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_UserService_ListAllUserStats_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@ -1767,23 +1708,6 @@ func RegisterUserServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux
}
forward_UserService_DeleteUser_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_UserService_GetUserAvatar_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.UserService/GetUserAvatar", runtime.WithHTTPPathPattern("/api/v1/{name=users/*}/avatar"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_UserService_GetUserAvatar_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_UserService_GetUserAvatar_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodGet, pattern_UserService_ListAllUserStats_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
@ -2082,7 +2006,6 @@ var (
pattern_UserService_CreateUser_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "users"}, ""))
pattern_UserService_UpdateUser_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "users", "user.name"}, ""))
pattern_UserService_DeleteUser_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "users", "name"}, ""))
pattern_UserService_GetUserAvatar_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3, 2, 4}, []string{"api", "v1", "users", "name", "avatar"}, ""))
pattern_UserService_ListAllUserStats_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"api", "v1", "users"}, "stats"))
pattern_UserService_GetUserStats_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 2, 5, 3}, []string{"api", "v1", "users", "name"}, "getStats"))
pattern_UserService_GetUserSetting_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", "users", "settings", "name"}, ""))
@ -2108,7 +2031,6 @@ var (
forward_UserService_CreateUser_0 = runtime.ForwardResponseMessage
forward_UserService_UpdateUser_0 = runtime.ForwardResponseMessage
forward_UserService_DeleteUser_0 = runtime.ForwardResponseMessage
forward_UserService_GetUserAvatar_0 = runtime.ForwardResponseMessage
forward_UserService_ListAllUserStats_0 = runtime.ForwardResponseMessage
forward_UserService_GetUserStats_0 = runtime.ForwardResponseMessage
forward_UserService_GetUserSetting_0 = runtime.ForwardResponseMessage

View File

@ -8,7 +8,6 @@ package apiv1
import (
context "context"
httpbody "google.golang.org/genproto/googleapis/api/httpbody"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
@ -26,7 +25,6 @@ const (
UserService_CreateUser_FullMethodName = "/memos.api.v1.UserService/CreateUser"
UserService_UpdateUser_FullMethodName = "/memos.api.v1.UserService/UpdateUser"
UserService_DeleteUser_FullMethodName = "/memos.api.v1.UserService/DeleteUser"
UserService_GetUserAvatar_FullMethodName = "/memos.api.v1.UserService/GetUserAvatar"
UserService_ListAllUserStats_FullMethodName = "/memos.api.v1.UserService/ListAllUserStats"
UserService_GetUserStats_FullMethodName = "/memos.api.v1.UserService/GetUserStats"
UserService_GetUserSetting_FullMethodName = "/memos.api.v1.UserService/GetUserSetting"
@ -63,8 +61,6 @@ type UserServiceClient interface {
UpdateUser(ctx context.Context, in *UpdateUserRequest, opts ...grpc.CallOption) (*User, error)
// DeleteUser deletes a user.
DeleteUser(ctx context.Context, in *DeleteUserRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
// GetUserAvatar gets the avatar of a user.
GetUserAvatar(ctx context.Context, in *GetUserAvatarRequest, opts ...grpc.CallOption) (*httpbody.HttpBody, error)
// ListAllUserStats returns statistics for all users.
ListAllUserStats(ctx context.Context, in *ListAllUserStatsRequest, opts ...grpc.CallOption) (*ListAllUserStatsResponse, error)
// GetUserStats returns statistics for a specific user.
@ -159,16 +155,6 @@ func (c *userServiceClient) DeleteUser(ctx context.Context, in *DeleteUserReques
return out, nil
}
func (c *userServiceClient) GetUserAvatar(ctx context.Context, in *GetUserAvatarRequest, opts ...grpc.CallOption) (*httpbody.HttpBody, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(httpbody.HttpBody)
err := c.cc.Invoke(ctx, UserService_GetUserAvatar_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userServiceClient) ListAllUserStats(ctx context.Context, in *ListAllUserStatsRequest, opts ...grpc.CallOption) (*ListAllUserStatsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListAllUserStatsResponse)
@ -356,8 +342,6 @@ type UserServiceServer interface {
UpdateUser(context.Context, *UpdateUserRequest) (*User, error)
// DeleteUser deletes a user.
DeleteUser(context.Context, *DeleteUserRequest) (*emptypb.Empty, error)
// GetUserAvatar gets the avatar of a user.
GetUserAvatar(context.Context, *GetUserAvatarRequest) (*httpbody.HttpBody, error)
// ListAllUserStats returns statistics for all users.
ListAllUserStats(context.Context, *ListAllUserStatsRequest) (*ListAllUserStatsResponse, error)
// GetUserStats returns statistics for a specific user.
@ -417,9 +401,6 @@ func (UnimplementedUserServiceServer) UpdateUser(context.Context, *UpdateUserReq
func (UnimplementedUserServiceServer) DeleteUser(context.Context, *DeleteUserRequest) (*emptypb.Empty, error) {
return nil, status.Error(codes.Unimplemented, "method DeleteUser not implemented")
}
func (UnimplementedUserServiceServer) GetUserAvatar(context.Context, *GetUserAvatarRequest) (*httpbody.HttpBody, error) {
return nil, status.Error(codes.Unimplemented, "method GetUserAvatar not implemented")
}
func (UnimplementedUserServiceServer) ListAllUserStats(context.Context, *ListAllUserStatsRequest) (*ListAllUserStatsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListAllUserStats not implemented")
}
@ -582,24 +563,6 @@ func _UserService_DeleteUser_Handler(srv interface{}, ctx context.Context, dec f
return interceptor(ctx, in, info, handler)
}
func _UserService_GetUserAvatar_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetUserAvatarRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserServiceServer).GetUserAvatar(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserService_GetUserAvatar_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserServiceServer).GetUserAvatar(ctx, req.(*GetUserAvatarRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserService_ListAllUserStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListAllUserStatsRequest)
if err := dec(in); err != nil {
@ -933,10 +896,6 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
MethodName: "DeleteUser",
Handler: _UserService_DeleteUser_Handler,
},
{
MethodName: "GetUserAvatar",
Handler: _UserService_GetUserAvatar_Handler,
},
{
MethodName: "ListAllUserStats",
Handler: _UserService_ListAllUserStats_Handler,

View File

@ -1322,30 +1322,6 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Status'
/api/v1/users/{user}/avatar:
get:
tags:
- UserService
description: GetUserAvatar gets the avatar of a user.
operationId: UserService_GetUserAvatar
parameters:
- name: user
in: path
description: The user 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/users/{user}/notifications:
get:
tags:
@ -1968,41 +1944,6 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Status'
/file/attachments/{attachment}/{filename:
get:
tags:
- AttachmentService
description: GetAttachmentBinary returns a attachment binary by name.
operationId: AttachmentService_GetAttachmentBinary
parameters:
- name: filename
in: path
description: The filename of the attachment. Mainly used for downloading.
required: true
schema:
type: string
- name: attachment
in: path
description: The attachment id.
required: true
schema:
type: string
- name: thumbnail
in: query
description: Optional. A flag indicating if the thumbnail version of the attachment should be returned.
schema:
type: boolean
responses:
"200":
description: OK
content:
'*/*': {}
default:
description: Default error response
content:
application/json:
schema:
$ref: '#/components/schemas/Status'
components:
schemas:
Activity:

View File

@ -6,21 +6,15 @@ import (
"encoding/binary"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/disintegration/imaging"
"github.com/lithammer/shortuuid/v4"
"github.com/pkg/errors"
"google.golang.org/genproto/googleapis/api/httpbody"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
@ -197,129 +191,6 @@ func (s *APIV1Service) GetAttachment(ctx context.Context, request *v1pb.GetAttac
return convertAttachmentFromStore(attachment), nil
}
func (s *APIV1Service) GetAttachmentBinary(ctx context.Context, request *v1pb.GetAttachmentBinaryRequest) (*httpbody.HttpBody, error) {
attachmentUID, err := ExtractAttachmentUIDFromName(request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid attachment id: %v", err)
}
attachment, err := s.Store.GetAttachment(ctx, &store.FindAttachment{
GetBlob: true,
UID: &attachmentUID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get attachment: %v", err)
}
if attachment == nil {
return nil, status.Errorf(codes.NotFound, "attachment not found")
}
// Check the related memo visibility.
if attachment.MemoID != nil {
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
ID: attachment.MemoID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to find memo by ID: %v", attachment.MemoID)
}
if memo != nil && memo.Visibility != store.Public {
user, err := s.GetCurrentUser(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
}
if user == nil {
return nil, status.Errorf(codes.Unauthenticated, "unauthorized access")
}
if memo.Visibility == store.Private && user.ID != attachment.CreatorID {
return nil, status.Errorf(codes.Unauthenticated, "unauthorized access")
}
}
}
if request.Thumbnail && util.HasPrefixes(attachment.Type, SupportedThumbnailMimeTypes...) {
// Skip server-side thumbnail generation for S3 storage to reduce memory usage.
// S3 images use external links (presigned URLs) directly, which avoids:
// 1. Downloading large images from S3 into server memory
// 2. Decoding and resizing images on the server
// 3. High memory consumption when many thumbnails are requested at once
// The client will use the external link and can implement client-side thumbnail logic if needed.
if attachment.StorageType == storepb.AttachmentStorageType_S3 {
slog.Debug("skipping server-side thumbnail for S3-stored image to reduce memory usage")
// Fall through to return the full image via external link
} else {
// Generate thumbnails for local and database storage
thumbnailBlob, err := s.getOrGenerateThumbnail(ctx, attachment)
if err != nil {
// thumbnail failures are logged as warnings and not cosidered critical failures as
// a attachment image can be used in its place.
slog.Warn("failed to get attachment thumbnail image", slog.Any("error", err))
} else {
return &httpbody.HttpBody{
ContentType: attachment.Type,
Data: thumbnailBlob,
}, nil
}
}
}
blob, err := s.GetAttachmentBlob(attachment)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get attachment blob: %v", err)
}
contentType := attachment.Type
if strings.HasPrefix(contentType, "text/") {
contentType += "; charset=utf-8"
}
// Prevent XSS attacks by serving potentially unsafe files with a content type that prevents script execution.
if strings.EqualFold(contentType, "image/svg+xml") ||
strings.EqualFold(contentType, "text/html") ||
strings.EqualFold(contentType, "application/xhtml+xml") {
contentType = "application/octet-stream"
}
// Extract range header from gRPC metadata for iOS Safari video support
var rangeHeader string
if md, ok := metadata.FromIncomingContext(ctx); ok {
// Check for range header from gRPC-Gateway
if ranges := md.Get("grpcgateway-range"); len(ranges) > 0 {
rangeHeader = ranges[0]
} else if ranges := md.Get("range"); len(ranges) > 0 {
rangeHeader = ranges[0]
}
// Log for debugging iOS Safari issues
if userAgents := md.Get("user-agent"); len(userAgents) > 0 {
userAgent := userAgents[0]
if strings.Contains(strings.ToLower(userAgent), "safari") && rangeHeader != "" {
slog.Debug("Safari range request detected",
slog.String("range", rangeHeader),
slog.String("user-agent", userAgent),
slog.String("content-type", contentType))
}
}
}
// Handle range requests for video/audio streaming (iOS Safari requirement)
if rangeHeader != "" && (strings.HasPrefix(contentType, "video/") || strings.HasPrefix(contentType, "audio/")) {
return s.handleRangeRequest(ctx, blob, rangeHeader, contentType)
}
// Set headers for streaming support
if strings.HasPrefix(contentType, "video/") || strings.HasPrefix(contentType, "audio/") {
if err := setResponseHeaders(ctx, map[string]string{
"accept-ranges": "bytes",
"content-length": fmt.Sprintf("%d", len(blob)),
"cache-control": "public, max-age=3600", // 1 hour cache
}); err != nil {
slog.Warn("failed to set streaming headers", slog.Any("error", err))
}
}
return &httpbody.HttpBody{
ContentType: contentType,
Data: blob,
}, nil
}
func (s *APIV1Service) UpdateAttachment(ctx context.Context, request *v1pb.UpdateAttachmentRequest) (*v1pb.Attachment, error) {
attachmentUID, err := ExtractAttachmentUIDFromName(request.Attachment.Name)
if err != nil {
@ -541,113 +412,6 @@ func (s *APIV1Service) GetAttachmentBlob(attachment *store.Attachment) ([]byte,
return attachment.Blob, nil
}
const (
// thumbnailMaxSize is the maximum size in pixels for the largest dimension of the thumbnail image.
thumbnailMaxSize = 600
)
// getOrGenerateThumbnail returns the thumbnail image of the attachment.
// Uses semaphore to limit concurrent thumbnail generation and prevent memory exhaustion.
func (s *APIV1Service) getOrGenerateThumbnail(ctx context.Context, attachment *store.Attachment) ([]byte, error) {
thumbnailCacheFolder := filepath.Join(s.Profile.Data, ThumbnailCacheFolder)
if err := os.MkdirAll(thumbnailCacheFolder, os.ModePerm); err != nil {
return nil, errors.Wrap(err, "failed to create thumbnail cache folder")
}
filePath := filepath.Join(thumbnailCacheFolder, fmt.Sprintf("%d%s", attachment.ID, filepath.Ext(attachment.Filename)))
// Check if thumbnail already exists
if _, err := os.Stat(filePath); err == nil {
// Thumbnail exists, read and return it
thumbnailFile, err := os.Open(filePath)
if err != nil {
return nil, errors.Wrap(err, "failed to open thumbnail file")
}
defer thumbnailFile.Close()
blob, err := io.ReadAll(thumbnailFile)
if err != nil {
return nil, errors.Wrap(err, "failed to read thumbnail file")
}
return blob, nil
} else if !os.IsNotExist(err) {
return nil, errors.Wrap(err, "failed to check thumbnail image stat")
}
// Thumbnail doesn't exist, acquire semaphore to limit concurrent generation
if err := s.thumbnailSemaphore.Acquire(ctx, 1); err != nil {
return nil, errors.Wrap(err, "failed to acquire thumbnail generation semaphore")
}
defer s.thumbnailSemaphore.Release(1)
// Double-check if thumbnail was created while waiting for semaphore
if _, err := os.Stat(filePath); err == nil {
thumbnailFile, err := os.Open(filePath)
if err != nil {
return nil, errors.Wrap(err, "failed to open thumbnail file")
}
defer thumbnailFile.Close()
blob, err := io.ReadAll(thumbnailFile)
if err != nil {
return nil, errors.Wrap(err, "failed to read thumbnail file")
}
return blob, nil
}
// Generate the thumbnail
blob, err := s.GetAttachmentBlob(attachment)
if err != nil {
return nil, errors.Wrap(err, "failed to get attachment blob")
}
// Decode image - this is memory intensive
img, err := imaging.Decode(bytes.NewReader(blob), imaging.AutoOrientation(true))
if err != nil {
return nil, errors.Wrap(err, "failed to decode thumbnail image")
}
// The largest dimension is set to thumbnailMaxSize and the smaller dimension is scaled proportionally.
// Small images are not enlarged.
width := img.Bounds().Dx()
height := img.Bounds().Dy()
var thumbnailWidth, thumbnailHeight int
// Only resize if the image is larger than thumbnailMaxSize
if max(width, height) > thumbnailMaxSize {
if width >= height {
// Landscape or square - constrain width, maintain aspect ratio for height
thumbnailWidth = thumbnailMaxSize
thumbnailHeight = 0
} else {
// Portrait - constrain height, maintain aspect ratio for width
thumbnailWidth = 0
thumbnailHeight = thumbnailMaxSize
}
} else {
// Keep original dimensions for small images
thumbnailWidth = width
thumbnailHeight = height
}
// Resize the image to the calculated dimensions.
thumbnailImage := imaging.Resize(img, thumbnailWidth, thumbnailHeight, imaging.Lanczos)
// Save thumbnail to disk
if err := imaging.Save(thumbnailImage, filePath); err != nil {
return nil, errors.Wrap(err, "failed to save thumbnail file")
}
// Read the saved thumbnail and return it
thumbnailFile, err := os.Open(filePath)
if err != nil {
return nil, errors.Wrap(err, "failed to open thumbnail file")
}
defer thumbnailFile.Close()
thumbnailBlob, err := io.ReadAll(thumbnailFile)
if err != nil {
return nil, errors.Wrap(err, "failed to read thumbnail file")
}
return thumbnailBlob, nil
}
var fileKeyPattern = regexp.MustCompile(`\{[a-z]{1,9}\}`)
func replaceFilenameWithPathTemplate(path, filename string) string {
@ -679,85 +443,6 @@ func replaceFilenameWithPathTemplate(path, filename string) string {
return path
}
// handleRangeRequest handles HTTP range requests for video/audio streaming (iOS Safari requirement).
func (*APIV1Service) handleRangeRequest(ctx context.Context, data []byte, rangeHeader, contentType string) (*httpbody.HttpBody, error) {
// Parse "bytes=start-end"
if !strings.HasPrefix(rangeHeader, "bytes=") {
return nil, status.Errorf(codes.InvalidArgument, "invalid range header format")
}
rangeSpec := strings.TrimPrefix(rangeHeader, "bytes=")
parts := strings.Split(rangeSpec, "-")
if len(parts) != 2 {
return nil, status.Errorf(codes.InvalidArgument, "invalid range specification")
}
fileSize := int64(len(data))
start, end := int64(0), fileSize-1
// Parse start position
if parts[0] != "" {
if s, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
start = s
} else {
return nil, status.Errorf(codes.InvalidArgument, "invalid range start: %s", parts[0])
}
}
// Parse end position
if parts[1] != "" {
if e, err := strconv.ParseInt(parts[1], 10, 64); err == nil {
end = e
} else {
return nil, status.Errorf(codes.InvalidArgument, "invalid range end: %s", parts[1])
}
}
// Validate range
if start < 0 || end >= fileSize || start > end {
// Set Content-Range header for 416 response
if err := setResponseHeaders(ctx, map[string]string{
"content-range": fmt.Sprintf("bytes */%d", fileSize),
}); err != nil {
slog.Warn("failed to set content-range header", slog.Any("error", err))
}
return nil, status.Errorf(codes.OutOfRange, "requested range not satisfiable")
}
// Set partial content headers (HTTP 206)
if err := setResponseHeaders(ctx, map[string]string{
"accept-ranges": "bytes",
"content-range": fmt.Sprintf("bytes %d-%d/%d", start, end, fileSize),
"content-length": fmt.Sprintf("%d", end-start+1),
"cache-control": "public, max-age=3600",
}); err != nil {
slog.Warn("failed to set partial content headers", slog.Any("error", err))
}
// Extract the requested range
rangeData := data[start : end+1]
slog.Debug("serving partial content",
slog.Int64("start", start),
slog.Int64("end", end),
slog.Int64("total", fileSize),
slog.Int("chunk_size", len(rangeData)))
return &httpbody.HttpBody{
ContentType: contentType,
Data: rangeData,
}, nil
}
// setResponseHeaders is a helper function to set gRPC response headers.
func setResponseHeaders(ctx context.Context, headers map[string]string) error {
pairs := make([]string, 0, len(headers)*2)
for key, value := range headers {
pairs = append(pairs, key, value)
}
return grpc.SetHeader(ctx, metadata.Pairs(pairs...))
}
func validateFilename(filename string) bool {
// Reject path traversal attempts and make sure no additional directories are created
if !filepath.IsLocal(filename) || strings.ContainsAny(filename, "/\\") {

View File

@ -3,7 +3,6 @@ package v1
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
@ -19,7 +18,6 @@ import (
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"golang.org/x/crypto/bcrypt"
"google.golang.org/genproto/googleapis/api/httpbody"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
@ -106,39 +104,6 @@ func (s *APIV1Service) GetUser(ctx context.Context, request *v1pb.GetUserRequest
return convertUserFromStore(user), nil
}
func (s *APIV1Service) GetUserAvatar(ctx context.Context, request *v1pb.GetUserAvatarRequest) (*httpbody.HttpBody, error) {
userID, err := ExtractUserIDFromName(request.Name)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
}
if user == nil {
return nil, status.Errorf(codes.NotFound, "user not found")
}
if user.AvatarURL == "" {
return nil, status.Errorf(codes.NotFound, "avatar not found")
}
imageType, base64Data, err := extractImageInfo(user.AvatarURL)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to extract image info: %v", err)
}
imageData, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to decode string: %v", err)
}
httpBody := &httpbody.HttpBody{
ContentType: imageType,
Data: imageData,
}
return httpBody, nil
}
func (s *APIV1Service) CreateUser(ctx context.Context, request *v1pb.CreateUserRequest) (*v1pb.User, error) {
// Get current user (might be nil for unauthenticated requests)
currentUser, _ := s.GetCurrentUser(ctx)
@ -1151,7 +1116,7 @@ func convertUserFromStore(user *store.User) *v1pb.User {
// Check if avatar url is base64 format.
_, _, err := extractImageInfo(user.AvatarURL)
if err == nil {
userpb.AvatarUrl = fmt.Sprintf("/api/v1/%s/avatar", userpb.Name)
userpb.AvatarUrl = fmt.Sprintf("/file/%s/avatar", userpb.Name)
} else {
userpb.AvatarUrl = user.AvatarURL
}
@ -1183,11 +1148,13 @@ func convertUserRoleToStore(role v1pb.User_Role) store.Role {
}
}
// extractImageInfo extracts image type and base64 data from a data URI.
// Data URI format: data:image/png;base64,iVBORw0KGgo...
func extractImageInfo(dataURI string) (string, string, error) {
dataURIRegex := regexp.MustCompile(`^data:(?P<type>.+);base64,(?P<base64>.+)`)
matches := dataURIRegex.FindStringSubmatch(dataURI)
if len(matches) != 3 {
return "", "", errors.New("Invalid data URI format")
return "", "", errors.New("invalid data URI format")
}
imageType := matches[1]
base64Data := matches[2]

View File

@ -0,0 +1,309 @@
# Fileserver Package
## Overview
The `fileserver` package handles all binary file serving for Memos using native HTTP handlers. It was created to replace gRPC-based binary serving, which had limitations with HTTP range requests (required for Safari video/audio playback).
## Responsibilities
- Serve attachment binary files (images, videos, audio, documents)
- Serve user avatar images
- Handle HTTP range requests for video/audio streaming
- Authenticate requests using session cookies or JWT tokens
- Check permissions for private content
- Generate and serve image thumbnails
- Prevent XSS attacks on uploaded content
- Support S3 external storage
## Architecture
### Design Principles
1. **Separation of Concerns**: Binary files via HTTP, metadata via gRPC
2. **DRY**: Imports auth constants from `api/v1` package (single source of truth)
3. **Security First**: Authentication, authorization, and XSS prevention
4. **Performance**: Native HTTP streaming with proper caching headers
### Package Structure
```
fileserver/
├── fileserver.go # Main service and HTTP handlers
├── README.md # This file
└── fileserver_test.go # Tests (to be added)
```
## API Endpoints
### 1. Attachment Binary
```
GET /file/attachments/:uid/:filename[?thumbnail=true]
```
**Parameters:**
- `uid` - Attachment unique identifier
- `filename` - Original filename
- `thumbnail` (optional) - Return thumbnail for images
**Authentication:** Required for non-public memos
**Response:**
- `200 OK` - File content with proper Content-Type
- `206 Partial Content` - For range requests (video/audio)
- `401 Unauthorized` - Authentication required
- `403 Forbidden` - User not authorized
- `404 Not Found` - Attachment not found
**Headers:**
- `Content-Type` - MIME type of the file
- `Cache-Control: public, max-age=3600`
- `Accept-Ranges: bytes` - For video/audio
- `Content-Range` - For partial responses (206)
### 2. User Avatar
```
GET /file/users/:identifier/avatar
```
**Parameters:**
- `identifier` - User ID (e.g., `1`) or username (e.g., `steven`)
**Authentication:** Not required (avatars are public)
**Response:**
- `200 OK` - Avatar image (PNG/JPEG)
- `404 Not Found` - User not found or no avatar set
**Headers:**
- `Content-Type` - image/png or image/jpeg
- `Cache-Control: public, max-age=3600`
## Authentication
### Supported Methods
The fileserver supports two authentication methods, checked in order:
1. **Session Cookie** (`user_session`)
- Cookie format: `{userID}-{sessionID}`
- Validates session exists and hasn't expired (14-day sliding window)
- Updates last accessed time on success
2. **JWT Bearer Token** (`Authorization: Bearer {token}`)
- Validates JWT signature using server secret
- Checks token exists in user's access tokens (for revocation)
- Extracts user ID from token claims
### Authentication Flow
```
Request → getCurrentUser()
├─→ Try Session Cookie
│ ├─→ Parse cookie value
│ ├─→ Get user from DB
│ ├─→ Validate session
│ └─→ Return user (if valid)
└─→ Try JWT Token
├─→ Parse Authorization header
├─→ Verify JWT signature
├─→ Get user from DB
├─→ Validate token in access tokens list
└─→ Return user (if valid)
```
### Permission Model
**Attachments:**
- Unlinked: Public (no auth required)
- Public memo: Public (no auth required)
- Protected memo: Requires authentication
- Private memo: Creator only
**Avatars:**
- Always public (no auth required)
## Key Functions
### HTTP Handlers
#### `serveAttachmentFile(c echo.Context) error`
Main handler for attachment binary serving.
**Flow:**
1. Extract UID from URL parameter
2. Fetch attachment from database
3. Check permissions (memo visibility)
4. Get binary blob (local file, S3, or database)
5. Handle thumbnail request (if applicable)
6. Set security headers (XSS prevention)
7. Serve with range request support (video/audio)
#### `serveUserAvatar(c echo.Context) error`
Main handler for user avatar serving.
**Flow:**
1. Extract identifier (ID or username) from URL
2. Lookup user in database
3. Check if avatar exists
4. Decode base64 data URI
5. Serve with proper content type and caching
### Authentication
#### `getCurrentUser(ctx, c) (*store.User, error)`
Authenticates request using session cookie or JWT token.
#### `authenticateBySession(ctx, cookie) (*store.User, error)`
Validates session cookie and returns authenticated user.
#### `authenticateByJWT(ctx, token) (*store.User, error)`
Validates JWT access token and returns authenticated user.
### Permission Checks
#### `checkAttachmentPermission(ctx, c, attachment) error`
Validates user has permission to access attachment based on memo visibility.
### File Operations
#### `getAttachmentBlob(attachment) ([]byte, error)`
Retrieves binary content from local storage, S3, or database.
#### `getOrGenerateThumbnail(ctx, attachment) ([]byte, error)`
Returns cached thumbnail or generates new one (with semaphore limiting).
### Utilities
#### `getUserByIdentifier(ctx, identifier) (*store.User, error)`
Finds user by ID (int) or username (string).
#### `extractImageInfo(dataURI) (type, base64, error)`
Parses data URI to extract MIME type and base64 data.
## Dependencies
### External Packages
- `github.com/labstack/echo/v4` - HTTP router and middleware
- `github.com/golang-jwt/jwt/v5` - JWT parsing and validation
- `github.com/disintegration/imaging` - Image thumbnail generation
- `golang.org/x/sync/semaphore` - Concurrency control for thumbnails
### Internal Packages
- `server/router/api/v1` - Auth constants (SessionCookieName, ClaimsMessage, etc.)
- `store` - Database operations
- `internal/profile` - Server configuration
- `plugin/storage/s3` - S3 storage client
## Configuration
### Constants
All auth-related constants are imported from `server/router/api/v1/auth.go`:
- `apiv1.SessionCookieName` - "user_session"
- `apiv1.SessionSlidingDuration` - 14 days
- `apiv1.KeyID` - "v1" (JWT key identifier)
- `apiv1.ClaimsMessage` - JWT claims struct
Package-specific constants:
- `ThumbnailCacheFolder` - ".thumbnail_cache"
- `thumbnailMaxSize` - 600px
- `SupportedThumbnailMimeTypes` - ["image/png", "image/jpeg"]
## Error Handling
All handlers return Echo HTTP errors with appropriate status codes:
```go
// Bad request
echo.NewHTTPError(http.StatusBadRequest, "message")
// Unauthorized (no auth)
echo.NewHTTPError(http.StatusUnauthorized, "message")
// Forbidden (auth but no permission)
echo.NewHTTPError(http.StatusForbidden, "message")
// Not found
echo.NewHTTPError(http.StatusNotFound, "message")
// Internal error
echo.NewHTTPError(http.StatusInternalServerError, "message").SetInternal(err)
```
## Security Considerations
### 1. XSS Prevention
SVG and HTML files are served as `application/octet-stream` to prevent script execution:
```go
if contentType == "image/svg+xml" ||
contentType == "text/html" ||
contentType == "application/xhtml+xml" {
contentType = "application/octet-stream"
}
```
### 2. Authentication
Private content requires valid session or JWT token.
### 3. Authorization
Memo visibility rules enforced before serving attachments.
### 4. Input Validation
- Attachment UID validated from database
- User identifier validated (ID or username)
- Range requests validated before processing
## Performance Optimizations
### 1. Thumbnail Caching
Thumbnails cached on disk to avoid regeneration:
- Cache location: `{data_dir}/.thumbnail_cache/`
- Filename: `{attachment_id}{extension}`
- Semaphore limits concurrent generation (max 3)
### 2. HTTP Range Requests
Video/audio files use `http.ServeContent()` for efficient streaming:
- Automatic range parsing
- Efficient memory usage (streaming, not loading full file)
- Safari-compatible partial content responses
### 3. Caching Headers
All responses include cache headers:
```
Cache-Control: public, max-age=3600
```
### 4. S3 External Links
S3 files served via presigned URLs (no server download).
## Testing
### Unit Tests (To Add)
See SAFARI_FIX.md for recommended test coverage.
### Manual Testing
```bash
# Test attachment
curl "http://localhost:8081/file/attachments/{uid}/file.jpg"
# Test avatar by ID
curl "http://localhost:8081/file/users/1/avatar"
# Test avatar by username
curl "http://localhost:8081/file/users/steven/avatar"
# Test range request
curl -H "Range: bytes=0-999" "http://localhost:8081/file/attachments/{uid}/video.mp4"
```
## Future Improvements
See SAFARI_FIX.md section "Future Improvements" for planned enhancements.
## Related Documentation
- [SAFARI_FIX.md](../../../SAFARI_FIX.md) - Full migration guide
- [server/router/api/v1/auth.go](../api/v1/auth.go) - Auth constants source of truth
- [RFC 7233](https://tools.ietf.org/html/rfc7233) - HTTP Range Requests spec

View File

@ -0,0 +1,569 @@
package fileserver
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/disintegration/imaging"
"github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
"github.com/pkg/errors"
"golang.org/x/sync/semaphore"
"github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/internal/util"
"github.com/usememos/memos/plugin/storage/s3"
storepb "github.com/usememos/memos/proto/gen/store"
apiv1 "github.com/usememos/memos/server/router/api/v1"
"github.com/usememos/memos/store"
)
const (
// ThumbnailCacheFolder is the folder name where the thumbnail images are stored.
ThumbnailCacheFolder = ".thumbnail_cache"
// thumbnailMaxSize is the maximum size in pixels for the largest dimension of the thumbnail image.
thumbnailMaxSize = 600
)
var SupportedThumbnailMimeTypes = []string{
"image/png",
"image/jpeg",
}
// FileServerService handles HTTP file serving with proper range request support.
// This service bypasses gRPC-Gateway to use native HTTP serving via http.ServeContent(),
// which is required for Safari video/audio playback.
type FileServerService struct {
Profile *profile.Profile
Store *store.Store
Secret string
// thumbnailSemaphore limits concurrent thumbnail generation to prevent memory exhaustion
thumbnailSemaphore *semaphore.Weighted
}
// NewFileServerService creates a new file server service.
func NewFileServerService(profile *profile.Profile, store *store.Store, secret string) *FileServerService {
return &FileServerService{
Profile: profile,
Store: store,
Secret: secret,
thumbnailSemaphore: semaphore.NewWeighted(3), // Limit to 3 concurrent thumbnail generations
}
}
// RegisterRoutes registers HTTP file serving routes.
func (s *FileServerService) RegisterRoutes(echoServer *echo.Echo) {
fileGroup := echoServer.Group("/file")
// Serve attachment binary files
fileGroup.GET("/attachments/:uid/:filename", s.serveAttachmentFile)
// Serve user avatar images
fileGroup.GET("/users/:identifier/avatar", s.serveUserAvatar)
}
// serveAttachmentFile serves attachment binary content using native HTTP.
// This properly handles range requests required by Safari for video/audio playback.
func (s *FileServerService) serveAttachmentFile(c echo.Context) error {
ctx := c.Request().Context()
uid := c.Param("uid")
thumbnail := c.QueryParam("thumbnail") == "true"
// Get attachment from database
attachment, err := s.Store.GetAttachment(ctx, &store.FindAttachment{
UID: &uid,
GetBlob: true,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get attachment").SetInternal(err)
}
if attachment == nil {
return echo.NewHTTPError(http.StatusNotFound, "attachment not found")
}
// Check permissions - verify memo visibility if attachment belongs to a memo
if err := s.checkAttachmentPermission(ctx, c, attachment); err != nil {
return err
}
// Get the binary content
blob, err := s.getAttachmentBlob(attachment)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get attachment blob").SetInternal(err)
}
// Handle thumbnail requests for images
if thumbnail && s.isImageType(attachment.Type) {
thumbnailBlob, err := s.getOrGenerateThumbnail(ctx, attachment)
if err != nil {
// Log warning but fall back to original image
c.Logger().Warnf("failed to get thumbnail: %v", err)
} else {
blob = thumbnailBlob
}
}
// Determine content type
contentType := attachment.Type
if strings.HasPrefix(contentType, "text/") {
contentType += "; charset=utf-8"
}
// Prevent XSS attacks by serving potentially unsafe files as octet-stream
if strings.EqualFold(contentType, "image/svg+xml") ||
strings.EqualFold(contentType, "text/html") ||
strings.EqualFold(contentType, "application/xhtml+xml") {
contentType = "application/octet-stream"
}
// Set common headers
c.Response().Header().Set("Content-Type", contentType)
c.Response().Header().Set("Cache-Control", "public, max-age=3600")
// For video/audio: Use http.ServeContent for automatic range request support
// This is critical for Safari which REQUIRES range request support
if strings.HasPrefix(contentType, "video/") || strings.HasPrefix(contentType, "audio/") {
// ServeContent automatically handles:
// - Range request parsing
// - HTTP 206 Partial Content responses
// - Content-Range headers
// - Accept-Ranges: bytes header
modTime := time.Unix(attachment.UpdatedTs, 0)
http.ServeContent(c.Response(), c.Request(), attachment.Filename, modTime, bytes.NewReader(blob))
return nil
}
// For other files: Simple blob response
return c.Blob(http.StatusOK, contentType, blob)
}
// serveUserAvatar serves user avatar images.
// Supports both user ID and username as identifier.
func (s *FileServerService) serveUserAvatar(c echo.Context) error {
ctx := c.Request().Context()
identifier := c.Param("identifier")
// Try to find user by ID or username
user, err := s.getUserByIdentifier(ctx, identifier)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get user").SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusNotFound, "user not found")
}
if user.AvatarURL == "" {
return echo.NewHTTPError(http.StatusNotFound, "avatar not found")
}
// Extract image info from data URI
imageType, base64Data, err := s.extractImageInfo(user.AvatarURL)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to extract image info").SetInternal(err)
}
// Decode base64 data
imageData, err := base64.StdEncoding.DecodeString(base64Data)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to decode image data").SetInternal(err)
}
// Set cache headers for avatars
c.Response().Header().Set("Content-Type", imageType)
c.Response().Header().Set("Cache-Control", "public, max-age=3600")
return c.Blob(http.StatusOK, imageType, imageData)
}
// getUserByIdentifier finds a user by either ID or username.
func (s *FileServerService) getUserByIdentifier(ctx context.Context, identifier string) (*store.User, error) {
// Try to parse as ID first
if userID, err := util.ConvertStringToInt32(identifier); err == nil {
return s.Store.GetUser(ctx, &store.FindUser{ID: &userID})
}
// Otherwise, treat as username
return s.Store.GetUser(ctx, &store.FindUser{Username: &identifier})
}
// extractImageInfo extracts image type and base64 data from a data URI.
// Data URI format: data:image/png;base64,iVBORw0KGgo...
func (*FileServerService) extractImageInfo(dataURI string) (string, string, error) {
dataURIRegex := regexp.MustCompile(`^data:(?P<type>.+);base64,(?P<base64>.+)`)
matches := dataURIRegex.FindStringSubmatch(dataURI)
if len(matches) != 3 {
return "", "", errors.New("invalid data URI format")
}
imageType := matches[1]
base64Data := matches[2]
return imageType, base64Data, nil
}
// checkAttachmentPermission verifies the user has permission to access the attachment.
func (s *FileServerService) checkAttachmentPermission(ctx context.Context, c echo.Context, attachment *store.Attachment) error {
// If attachment is not linked to a memo, allow access
if attachment.MemoID == nil {
return nil
}
// Check memo visibility
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
ID: attachment.MemoID,
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to find memo").SetInternal(err)
}
if memo == nil {
return echo.NewHTTPError(http.StatusNotFound, "memo not found")
}
// Public memos are accessible to everyone
if memo.Visibility == store.Public {
return nil
}
// For non-public memos, check authentication
user, err := s.getCurrentUser(ctx, c)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get current user").SetInternal(err)
}
if user == nil {
return echo.NewHTTPError(http.StatusUnauthorized, "unauthorized access")
}
// Private memos can only be accessed by the creator
if memo.Visibility == store.Private && user.ID != attachment.CreatorID {
return echo.NewHTTPError(http.StatusForbidden, "forbidden access")
}
return nil
}
// getCurrentUser retrieves the current authenticated user from the Echo context.
// It checks both session cookies and Bearer tokens for authentication.
func (s *FileServerService) getCurrentUser(ctx context.Context, c echo.Context) (*store.User, error) {
// Try session cookie authentication first
if cookie, err := c.Cookie(apiv1.SessionCookieName); err == nil && cookie.Value != "" {
user, err := s.authenticateBySession(ctx, cookie.Value)
if err == nil && user != nil {
return user, nil
}
}
// Try JWT Bearer token authentication
authHeader := c.Request().Header.Get("Authorization")
if authHeader != "" {
parts := strings.Fields(authHeader)
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
user, err := s.authenticateByJWT(ctx, parts[1])
if err == nil && user != nil {
return user, nil
}
}
}
// No valid authentication found
return nil, nil
}
// authenticateBySession authenticates a user using session ID from cookie.
func (s *FileServerService) authenticateBySession(ctx context.Context, sessionCookieValue string) (*store.User, error) {
if sessionCookieValue == "" {
return nil, errors.New("session cookie value not found")
}
// Parse the cookie value to extract userID and sessionID
userID, sessionID, err := s.parseSessionCookieValue(sessionCookieValue)
if err != nil {
return nil, errors.Wrap(err, "invalid session cookie format")
}
// Get the user
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return nil, errors.Wrap(err, "failed to get user")
}
if user == nil {
return nil, errors.New("user not found")
}
if user.RowStatus == store.Archived {
return nil, errors.New("user is archived")
}
// Get user sessions and validate the sessionID
sessions, err := s.Store.GetUserSessions(ctx, user.ID)
if err != nil {
return nil, errors.Wrap(err, "failed to get user sessions")
}
if !s.validateUserSession(sessionID, sessions) {
return nil, errors.New("invalid or expired session")
}
return user, nil
}
// authenticateByJWT authenticates a user using JWT access token from Authorization header.
func (s *FileServerService) authenticateByJWT(ctx context.Context, accessToken string) (*store.User, error) {
if accessToken == "" {
return nil, errors.New("access token not found")
}
claims := &apiv1.ClaimsMessage{}
_, err := jwt.ParseWithClaims(accessToken, claims, func(t *jwt.Token) (any, error) {
if t.Method.Alg() != jwt.SigningMethodHS256.Name {
return nil, errors.Errorf("unexpected access token signing method=%v, expect %v", t.Header["alg"], jwt.SigningMethodHS256)
}
if kid, ok := t.Header["kid"].(string); ok {
if kid == apiv1.KeyID {
return []byte(s.Secret), nil
}
}
return nil, errors.Errorf("unexpected access token kid=%v", t.Header["kid"])
})
if err != nil {
return nil, errors.Wrap(err, "Invalid or expired access token")
}
// Get user from JWT claims
userID, err := util.ConvertStringToInt32(claims.Subject)
if err != nil {
return nil, errors.Wrap(err, "malformed ID in the token")
}
user, err := s.Store.GetUser(ctx, &store.FindUser{
ID: &userID,
})
if err != nil {
return nil, errors.Wrap(err, "failed to get user")
}
if user == nil {
return nil, errors.Errorf("user %q not exists", userID)
}
if user.RowStatus == store.Archived {
return nil, errors.Errorf("user %q is archived", userID)
}
// Validate that this access token exists in the user's access tokens
accessTokens, err := s.Store.GetUserAccessTokens(ctx, user.ID)
if err != nil {
return nil, errors.Wrapf(err, "failed to get user access tokens")
}
if !s.validateAccessToken(accessToken, accessTokens) {
return nil, errors.New("invalid access token")
}
return user, nil
}
// parseSessionCookieValue parses the session cookie value to extract userID and sessionID.
func (*FileServerService) parseSessionCookieValue(cookieValue string) (int32, string, error) {
parts := strings.SplitN(cookieValue, "-", 2)
if len(parts) != 2 {
return 0, "", errors.New("invalid session cookie format")
}
userID, err := util.ConvertStringToInt32(parts[0])
if err != nil {
return 0, "", errors.Errorf("invalid user ID in session cookie: %v", err)
}
return userID, parts[1], nil
}
// validateUserSession checks if a session exists and is still valid using sliding expiration.
func (*FileServerService) validateUserSession(sessionID string, userSessions []*storepb.SessionsUserSetting_Session) bool {
for _, session := range userSessions {
if sessionID == session.SessionId {
// Use sliding expiration: check if last_accessed_time + 14 days > current_time
if session.LastAccessedTime != nil {
expirationTime := session.LastAccessedTime.AsTime().Add(apiv1.SessionSlidingDuration)
if expirationTime.Before(time.Now()) {
return false
}
}
return true
}
}
return false
}
// validateAccessToken checks if the provided JWT token exists in the user's access tokens list.
func (*FileServerService) validateAccessToken(accessTokenString string, userAccessTokens []*storepb.AccessTokensUserSetting_AccessToken) bool {
for _, userAccessToken := range userAccessTokens {
if accessTokenString == userAccessToken.AccessToken {
return true
}
}
return false
}
// isImageType checks if the mime type is an image that supports thumbnails.
func (*FileServerService) isImageType(mimeType string) bool {
return mimeType == "image/png" || mimeType == "image/jpeg"
}
// getAttachmentBlob retrieves the binary content of an attachment from storage.
func (s *FileServerService) getAttachmentBlob(attachment *store.Attachment) ([]byte, error) {
// For local storage, read the file from the local disk.
if attachment.StorageType == storepb.AttachmentStorageType_LOCAL {
attachmentPath := filepath.FromSlash(attachment.Reference)
if !filepath.IsAbs(attachmentPath) {
attachmentPath = filepath.Join(s.Profile.Data, attachmentPath)
}
file, err := os.Open(attachmentPath)
if err != nil {
if os.IsNotExist(err) {
return nil, errors.Wrap(err, "file not found")
}
return nil, errors.Wrap(err, "failed to open the file")
}
defer file.Close()
blob, err := io.ReadAll(file)
if err != nil {
return nil, errors.Wrap(err, "failed to read the file")
}
return blob, nil
}
// For S3 storage, download the file from S3.
if attachment.StorageType == storepb.AttachmentStorageType_S3 {
if attachment.Payload == nil {
return nil, errors.New("attachment payload is missing")
}
s3Object := attachment.Payload.GetS3Object()
if s3Object == nil {
return nil, errors.New("S3 object payload is missing")
}
if s3Object.S3Config == nil {
return nil, errors.New("S3 config is missing")
}
if s3Object.Key == "" {
return nil, errors.New("S3 object key is missing")
}
s3Client, err := s3.NewClient(context.Background(), s3Object.S3Config)
if err != nil {
return nil, errors.Wrap(err, "failed to create S3 client")
}
blob, err := s3Client.GetObject(context.Background(), s3Object.Key)
if err != nil {
return nil, errors.Wrap(err, "failed to get object from S3")
}
return blob, nil
}
// For database storage, return the blob from the database.
return attachment.Blob, nil
}
// getOrGenerateThumbnail returns the thumbnail image of the attachment.
// Uses semaphore to limit concurrent thumbnail generation and prevent memory exhaustion.
func (s *FileServerService) getOrGenerateThumbnail(ctx context.Context, attachment *store.Attachment) ([]byte, error) {
thumbnailCacheFolder := filepath.Join(s.Profile.Data, ThumbnailCacheFolder)
if err := os.MkdirAll(thumbnailCacheFolder, os.ModePerm); err != nil {
return nil, errors.Wrap(err, "failed to create thumbnail cache folder")
}
filePath := filepath.Join(thumbnailCacheFolder, fmt.Sprintf("%d%s", attachment.ID, filepath.Ext(attachment.Filename)))
// Check if thumbnail already exists
if _, err := os.Stat(filePath); err == nil {
// Thumbnail exists, read and return it
thumbnailFile, err := os.Open(filePath)
if err != nil {
return nil, errors.Wrap(err, "failed to open thumbnail file")
}
defer thumbnailFile.Close()
blob, err := io.ReadAll(thumbnailFile)
if err != nil {
return nil, errors.Wrap(err, "failed to read thumbnail file")
}
return blob, nil
} else if !os.IsNotExist(err) {
return nil, errors.Wrap(err, "failed to check thumbnail image stat")
}
// Thumbnail doesn't exist, acquire semaphore to limit concurrent generation
if err := s.thumbnailSemaphore.Acquire(ctx, 1); err != nil {
return nil, errors.Wrap(err, "failed to acquire thumbnail generation semaphore")
}
defer s.thumbnailSemaphore.Release(1)
// Double-check if thumbnail was created while waiting for semaphore
if _, err := os.Stat(filePath); err == nil {
thumbnailFile, err := os.Open(filePath)
if err != nil {
return nil, errors.Wrap(err, "failed to open thumbnail file")
}
defer thumbnailFile.Close()
blob, err := io.ReadAll(thumbnailFile)
if err != nil {
return nil, errors.Wrap(err, "failed to read thumbnail file")
}
return blob, nil
}
// Generate the thumbnail
blob, err := s.getAttachmentBlob(attachment)
if err != nil {
return nil, errors.Wrap(err, "failed to get attachment blob")
}
// Decode image - this is memory intensive
img, err := imaging.Decode(bytes.NewReader(blob), imaging.AutoOrientation(true))
if err != nil {
return nil, errors.Wrap(err, "failed to decode thumbnail image")
}
// The largest dimension is set to thumbnailMaxSize and the smaller dimension is scaled proportionally.
// Small images are not enlarged.
width := img.Bounds().Dx()
height := img.Bounds().Dy()
var thumbnailWidth, thumbnailHeight int
// Only resize if the image is larger than thumbnailMaxSize
if max(width, height) > thumbnailMaxSize {
if width >= height {
// Landscape or square - constrain width, maintain aspect ratio for height
thumbnailWidth = thumbnailMaxSize
thumbnailHeight = 0
} else {
// Portrait - constrain height, maintain aspect ratio for width
thumbnailWidth = 0
thumbnailHeight = thumbnailMaxSize
}
} else {
// Keep original dimensions for small images
thumbnailWidth = width
thumbnailHeight = height
}
// Resize the image to the calculated dimensions.
thumbnailImage := imaging.Resize(img, thumbnailWidth, thumbnailHeight, imaging.Lanczos)
// Save thumbnail to disk
if err := imaging.Save(thumbnailImage, filePath); err != nil {
return nil, errors.Wrap(err, "failed to save thumbnail file")
}
// Read the saved thumbnail and return it
thumbnailFile, err := os.Open(filePath)
if err != nil {
return nil, errors.Wrap(err, "failed to open thumbnail file")
}
defer thumbnailFile.Close()
thumbnailBlob, err := io.ReadAll(thumbnailFile)
if err != nil {
return nil, errors.Wrap(err, "failed to read thumbnail file")
}
return thumbnailBlob, nil
}

View File

@ -22,6 +22,7 @@ import (
"github.com/usememos/memos/internal/profile"
storepb "github.com/usememos/memos/proto/gen/store"
apiv1 "github.com/usememos/memos/server/router/api/v1"
"github.com/usememos/memos/server/router/fileserver"
"github.com/usememos/memos/server/router/frontend"
"github.com/usememos/memos/server/router/rss"
"github.com/usememos/memos/server/runner/s3presign"
@ -87,6 +88,11 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
apiV1Service := apiv1.NewAPIV1Service(s.Secret, profile, store, grpcServer)
// Register HTTP file server routes BEFORE gRPC-Gateway to ensure proper range request handling for Safari.
// This uses native HTTP serving (http.ServeContent) instead of gRPC for video/audio files.
fileServerService := fileserver.NewFileServerService(s.Profile, s.Store, s.Secret)
fileServerService.RegisterRoutes(echoServer)
// Create and register RSS routes (needs markdown service from apiV1Service).
rss.NewRSSService(s.Profile, s.Store, apiV1Service.MarkdownService).RegisterRoutes(rootGroup)
// Register gRPC gateway as api v1.

View File

@ -6,7 +6,6 @@
/* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
import { HttpBody } from "../../google/api/httpbody";
import { Empty } from "../../google/protobuf/empty";
import { FieldMask } from "../../google/protobuf/field_mask";
import { Timestamp } from "../../google/protobuf/timestamp";
@ -99,18 +98,6 @@ export interface GetAttachmentRequest {
name: string;
}
export interface GetAttachmentBinaryRequest {
/**
* Required. The attachment name of the attachment.
* Format: attachments/{attachment}
*/
name: string;
/** The filename of the attachment. Mainly used for downloading. */
filename: string;
/** Optional. A flag indicating if the thumbnail version of the attachment should be returned. */
thumbnail: boolean;
}
export interface UpdateAttachmentRequest {
/** Required. The attachment which replaces the attachment on the server. */
attachment?:
@ -525,76 +512,6 @@ export const GetAttachmentRequest: MessageFns<GetAttachmentRequest> = {
},
};
function createBaseGetAttachmentBinaryRequest(): GetAttachmentBinaryRequest {
return { name: "", filename: "", thumbnail: false };
}
export const GetAttachmentBinaryRequest: MessageFns<GetAttachmentBinaryRequest> = {
encode(message: GetAttachmentBinaryRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.name !== "") {
writer.uint32(10).string(message.name);
}
if (message.filename !== "") {
writer.uint32(18).string(message.filename);
}
if (message.thumbnail !== false) {
writer.uint32(24).bool(message.thumbnail);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): GetAttachmentBinaryRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetAttachmentBinaryRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.name = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.filename = reader.string();
continue;
}
case 3: {
if (tag !== 24) {
break;
}
message.thumbnail = reader.bool();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
create(base?: DeepPartial<GetAttachmentBinaryRequest>): GetAttachmentBinaryRequest {
return GetAttachmentBinaryRequest.fromPartial(base ?? {});
},
fromPartial(object: DeepPartial<GetAttachmentBinaryRequest>): GetAttachmentBinaryRequest {
const message = createBaseGetAttachmentBinaryRequest();
message.name = object.name ?? "";
message.filename = object.filename ?? "";
message.thumbnail = object.thumbnail ?? false;
return message;
},
};
function createBaseUpdateAttachmentRequest(): UpdateAttachmentRequest {
return { attachment: undefined, updateMask: undefined };
}
@ -843,90 +760,6 @@ export const AttachmentServiceDefinition = {
},
},
},
/** GetAttachmentBinary returns a attachment binary by name. */
getAttachmentBinary: {
name: "GetAttachmentBinary",
requestType: GetAttachmentBinaryRequest,
requestStream: false,
responseType: HttpBody,
responseStream: false,
options: {
_unknownFields: {
8410: [
new Uint8Array([
23,
110,
97,
109,
101,
44,
102,
105,
108,
101,
110,
97,
109,
101,
44,
116,
104,
117,
109,
98,
110,
97,
105,
108,
]),
],
578365826: [
new Uint8Array([
39,
18,
37,
47,
102,
105,
108,
101,
47,
123,
110,
97,
109,
101,
61,
97,
116,
116,
97,
99,
104,
109,
101,
110,
116,
115,
47,
42,
125,
47,
123,
102,
105,
108,
101,
110,
97,
109,
101,
125,
]),
],
},
},
},
/** UpdateAttachment updates a attachment. */
updateAttachment: {
name: "UpdateAttachment",

View File

@ -6,7 +6,6 @@
/* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
import { HttpBody } from "../../google/api/httpbody";
import { Empty } from "../../google/protobuf/empty";
import { FieldMask } from "../../google/protobuf/field_mask";
import { Timestamp } from "../../google/protobuf/timestamp";
@ -189,14 +188,6 @@ export interface DeleteUserRequest {
force: boolean;
}
export interface GetUserAvatarRequest {
/**
* Required. The resource name of the user.
* Format: users/{user}
*/
name: string;
}
/** User statistics messages */
export interface UserStats {
/**
@ -1298,52 +1289,6 @@ export const DeleteUserRequest: MessageFns<DeleteUserRequest> = {
},
};
function createBaseGetUserAvatarRequest(): GetUserAvatarRequest {
return { name: "" };
}
export const GetUserAvatarRequest: MessageFns<GetUserAvatarRequest> = {
encode(message: GetUserAvatarRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.name !== "") {
writer.uint32(10).string(message.name);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): GetUserAvatarRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseGetUserAvatarRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.name = reader.string();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
create(base?: DeepPartial<GetUserAvatarRequest>): GetUserAvatarRequest {
return GetUserAvatarRequest.fromPartial(base ?? {});
},
fromPartial(object: DeepPartial<GetUserAvatarRequest>): GetUserAvatarRequest {
const message = createBaseGetUserAvatarRequest();
message.name = object.name ?? "";
return message;
},
};
function createBaseUserStats(): UserStats {
return {
name: "",
@ -3885,55 +3830,6 @@ export const UserServiceDefinition = {
},
},
},
/** GetUserAvatar gets the avatar of a user. */
getUserAvatar: {
name: "GetUserAvatar",
requestType: GetUserAvatarRequest,
requestStream: false,
responseType: HttpBody,
responseStream: false,
options: {
_unknownFields: {
8410: [new Uint8Array([4, 110, 97, 109, 101])],
578365826: [
new Uint8Array([
31,
18,
29,
47,
97,
112,
105,
47,
118,
49,
47,
123,
110,
97,
109,
101,
61,
117,
115,
101,
114,
115,
47,
42,
125,
47,
97,
118,
97,
116,
97,
114,
]),
],
},
},
},
/** ListAllUserStats returns statistics for all users. */
listAllUserStats: {
name: "ListAllUserStats",

View File

@ -1,152 +0,0 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc unknown
// source: google/api/httpbody.proto
/* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
import { Any } from "../protobuf/any";
export const protobufPackage = "google.api";
/**
* Message that represents an arbitrary HTTP body. It should only be used for
* payload formats that can't be represented as JSON, such as raw binary or
* an HTML page.
*
* This message can be used both in streaming and non-streaming API methods in
* the request as well as the response.
*
* It can be used as a top-level request field, which is convenient if one
* wants to extract parameters from either the URL or HTTP template into the
* request fields and also want access to the raw HTTP body.
*
* Example:
*
* message GetResourceRequest {
* // A unique request id.
* string request_id = 1;
*
* // The raw HTTP body is bound to this field.
* google.api.HttpBody http_body = 2;
*
* }
*
* service ResourceService {
* rpc GetResource(GetResourceRequest)
* returns (google.api.HttpBody);
* rpc UpdateResource(google.api.HttpBody)
* returns (google.protobuf.Empty);
*
* }
*
* Example with streaming methods:
*
* service CaldavService {
* rpc GetCalendar(stream google.api.HttpBody)
* returns (stream google.api.HttpBody);
* rpc UpdateCalendar(stream google.api.HttpBody)
* returns (stream google.api.HttpBody);
*
* }
*
* Use of this type only changes how the request and response bodies are
* handled, all other features will continue to work unchanged.
*/
export interface HttpBody {
/** The HTTP Content-Type header value specifying the content type of the body. */
contentType: string;
/** The HTTP request/response body as raw binary. */
data: Uint8Array;
/**
* Application specific response metadata. Must be set in the first response
* for streaming APIs.
*/
extensions: Any[];
}
function createBaseHttpBody(): HttpBody {
return { contentType: "", data: new Uint8Array(0), extensions: [] };
}
export const HttpBody: MessageFns<HttpBody> = {
encode(message: HttpBody, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.contentType !== "") {
writer.uint32(10).string(message.contentType);
}
if (message.data.length !== 0) {
writer.uint32(18).bytes(message.data);
}
for (const v of message.extensions) {
Any.encode(v!, writer.uint32(26).fork()).join();
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): HttpBody {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseHttpBody();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.contentType = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.data = reader.bytes();
continue;
}
case 3: {
if (tag !== 26) {
break;
}
message.extensions.push(Any.decode(reader, reader.uint32()));
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
create(base?: DeepPartial<HttpBody>): HttpBody {
return HttpBody.fromPartial(base ?? {});
},
fromPartial(object: DeepPartial<HttpBody>): HttpBody {
const message = createBaseHttpBody();
message.contentType = object.contentType ?? "";
message.data = object.data ?? new Uint8Array(0);
message.extensions = object.extensions?.map((e) => Any.fromPartial(e)) || [];
return message;
},
};
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
export type DeepPartial<T> = T extends Builtin ? T
: T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
export interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
create(base?: DeepPartial<T>): T;
fromPartial(object: DeepPartial<T>): T;
}

View File

@ -1,206 +0,0 @@
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
// versions:
// protoc-gen-ts_proto v2.6.1
// protoc unknown
// source: google/protobuf/any.proto
/* eslint-disable */
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
export const protobufPackage = "google.protobuf";
/**
* `Any` contains an arbitrary serialized protocol buffer message along with a
* URL that describes the type of the serialized message.
*
* Protobuf library provides support to pack/unpack Any values in the form
* of utility functions or additional generated methods of the Any type.
*
* Example 1: Pack and unpack a message in C++.
*
* Foo foo = ...;
* Any any;
* any.PackFrom(foo);
* ...
* if (any.UnpackTo(&foo)) {
* ...
* }
*
* Example 2: Pack and unpack a message in Java.
*
* Foo foo = ...;
* Any any = Any.pack(foo);
* ...
* if (any.is(Foo.class)) {
* foo = any.unpack(Foo.class);
* }
* // or ...
* if (any.isSameTypeAs(Foo.getDefaultInstance())) {
* foo = any.unpack(Foo.getDefaultInstance());
* }
*
* Example 3: Pack and unpack a message in Python.
*
* foo = Foo(...)
* any = Any()
* any.Pack(foo)
* ...
* if any.Is(Foo.DESCRIPTOR):
* any.Unpack(foo)
* ...
*
* Example 4: Pack and unpack a message in Go
*
* foo := &pb.Foo{...}
* any, err := anypb.New(foo)
* if err != nil {
* ...
* }
* ...
* foo := &pb.Foo{}
* if err := any.UnmarshalTo(foo); err != nil {
* ...
* }
*
* The pack methods provided by protobuf library will by default use
* 'type.googleapis.com/full.type.name' as the type URL and the unpack
* methods only use the fully qualified type name after the last '/'
* in the type URL, for example "foo.bar.com/x/y.z" will yield type
* name "y.z".
*
* JSON
* ====
* The JSON representation of an `Any` value uses the regular
* representation of the deserialized, embedded message, with an
* additional field `@type` which contains the type URL. Example:
*
* package google.profile;
* message Person {
* string first_name = 1;
* string last_name = 2;
* }
*
* {
* "@type": "type.googleapis.com/google.profile.Person",
* "firstName": <string>,
* "lastName": <string>
* }
*
* If the embedded message type is well-known and has a custom JSON
* representation, that representation will be embedded adding a field
* `value` which holds the custom JSON in addition to the `@type`
* field. Example (for message [google.protobuf.Duration][]):
*
* {
* "@type": "type.googleapis.com/google.protobuf.Duration",
* "value": "1.212s"
* }
*/
export interface Any {
/**
* A URL/resource name that uniquely identifies the type of the serialized
* protocol buffer message. This string must contain at least
* one "/" character. The last segment of the URL's path must represent
* the fully qualified name of the type (as in
* `path/google.protobuf.Duration`). The name should be in a canonical form
* (e.g., leading "." is not accepted).
*
* In practice, teams usually precompile into the binary all types that they
* expect it to use in the context of Any. However, for URLs which use the
* scheme `http`, `https`, or no scheme, one can optionally set up a type
* server that maps type URLs to message definitions as follows:
*
* * If no scheme is provided, `https` is assumed.
* * An HTTP GET on the URL must yield a [google.protobuf.Type][]
* value in binary format, or produce an error.
* * Applications are allowed to cache lookup results based on the
* URL, or have them precompiled into a binary to avoid any
* lookup. Therefore, binary compatibility needs to be preserved
* on changes to types. (Use versioned type names to manage
* breaking changes.)
*
* Note: this functionality is not currently available in the official
* protobuf release, and it is not used for type URLs beginning with
* type.googleapis.com. As of May 2023, there are no widely used type server
* implementations and no plans to implement one.
*
* Schemes other than `http`, `https` (or the empty scheme) might be
* used with implementation specific semantics.
*/
typeUrl: string;
/** Must be a valid serialized protocol buffer of the above specified type. */
value: Uint8Array;
}
function createBaseAny(): Any {
return { typeUrl: "", value: new Uint8Array(0) };
}
export const Any: MessageFns<Any> = {
encode(message: Any, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.typeUrl !== "") {
writer.uint32(10).string(message.typeUrl);
}
if (message.value.length !== 0) {
writer.uint32(18).bytes(message.value);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): Any {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
let end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseAny();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.typeUrl = reader.string();
continue;
}
case 2: {
if (tag !== 18) {
break;
}
message.value = reader.bytes();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
create(base?: DeepPartial<Any>): Any {
return Any.fromPartial(base ?? {});
},
fromPartial(object: DeepPartial<Any>): Any {
const message = createBaseAny();
message.typeUrl = object.typeUrl ?? "";
message.value = object.value ?? new Uint8Array(0);
return message;
},
};
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
export type DeepPartial<T> = T extends Builtin ? T
: T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
export interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
create(base?: DeepPartial<T>): T;
fromPartial(object: DeepPartial<T>): T;
}