diff --git a/%USERPROFILE%.dockerdaemon.json b/%USERPROFILE%.dockerdaemon.json deleted file mode 100644 index 11c4b9b..0000000 --- a/%USERPROFILE%.dockerdaemon.json +++ /dev/null @@ -1 +0,0 @@ -{"insecure-registries":["192.168.1.154:31010"]} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b5b8e0e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.gitignore +.idea +.vscode +docs +*.md +Jenkinsfile +.gitlab-ci.yml +.dockerignore +%USERPROFILE%.dockerdaemon.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e8c019e --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +RUSTFS_ENDPOINT_URL=http://your-rustfs-endpoint:9000 +RUSTFS_ACCESS_KEY_ID=your-access-key +RUSTFS_SECRET_ACCESS_KEY=your-secret-key +RUSTFS_REGION=us-east-1 +SERVER_PORT=8080 + +# 认证方式二选一:设置 GRPC_AUTH_ADDR 走 JWT/gRPC,否则走 API Key +AUTH_API_KEY=your-api-key +GRPC_AUTH_ADDR=localhost:50051 diff --git a/api/proto/auth.pb.go b/api/proto/auth.pb.go new file mode 100644 index 0000000..76036d3 --- /dev/null +++ b/api/proto/auth.pb.go @@ -0,0 +1,356 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: auth.proto + +package proto + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ValidateTokenRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ValidateTokenRequest) Reset() { + *x = ValidateTokenRequest{} + mi := &file_auth_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ValidateTokenRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ValidateTokenRequest) ProtoMessage() {} + +func (x *ValidateTokenRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_proto_msgTypes[0] + 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 ValidateTokenRequest.ProtoReflect.Descriptor instead. +func (*ValidateTokenRequest) Descriptor() ([]byte, []int) { + return file_auth_proto_rawDescGZIP(), []int{0} +} + +func (x *ValidateTokenRequest) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +type ValidateTokenResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Valid bool `protobuf:"varint,1,opt,name=valid,proto3" json:"valid,omitempty"` + UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Username string `protobuf:"bytes,3,opt,name=username,proto3" json:"username,omitempty"` + Email string `protobuf:"bytes,4,opt,name=email,proto3" json:"email,omitempty"` + Roles []string `protobuf:"bytes,5,rep,name=roles,proto3" json:"roles,omitempty"` + Permissions []string `protobuf:"bytes,6,rep,name=permissions,proto3" json:"permissions,omitempty"` + ExpiresAt int64 `protobuf:"varint,7,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ValidateTokenResponse) Reset() { + *x = ValidateTokenResponse{} + mi := &file_auth_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ValidateTokenResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ValidateTokenResponse) ProtoMessage() {} + +func (x *ValidateTokenResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_proto_msgTypes[1] + 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 ValidateTokenResponse.ProtoReflect.Descriptor instead. +func (*ValidateTokenResponse) Descriptor() ([]byte, []int) { + return file_auth_proto_rawDescGZIP(), []int{1} +} + +func (x *ValidateTokenResponse) GetValid() bool { + if x != nil { + return x.Valid + } + return false +} + +func (x *ValidateTokenResponse) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *ValidateTokenResponse) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *ValidateTokenResponse) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *ValidateTokenResponse) GetRoles() []string { + if x != nil { + return x.Roles + } + return nil +} + +func (x *ValidateTokenResponse) GetPermissions() []string { + if x != nil { + return x.Permissions + } + return nil +} + +func (x *ValidateTokenResponse) GetExpiresAt() int64 { + if x != nil { + return x.ExpiresAt + } + return 0 +} + +type CheckPermissionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + Permission string `protobuf:"bytes,2,opt,name=permission,proto3" json:"permission,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CheckPermissionRequest) Reset() { + *x = CheckPermissionRequest{} + mi := &file_auth_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CheckPermissionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CheckPermissionRequest) ProtoMessage() {} + +func (x *CheckPermissionRequest) ProtoReflect() protoreflect.Message { + mi := &file_auth_proto_msgTypes[2] + 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 CheckPermissionRequest.ProtoReflect.Descriptor instead. +func (*CheckPermissionRequest) Descriptor() ([]byte, []int) { + return file_auth_proto_rawDescGZIP(), []int{2} +} + +func (x *CheckPermissionRequest) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *CheckPermissionRequest) GetPermission() string { + if x != nil { + return x.Permission + } + return "" +} + +type CheckPermissionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Allowed bool `protobuf:"varint,1,opt,name=allowed,proto3" json:"allowed,omitempty"` + UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Roles []string `protobuf:"bytes,3,rep,name=roles,proto3" json:"roles,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CheckPermissionResponse) Reset() { + *x = CheckPermissionResponse{} + mi := &file_auth_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CheckPermissionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CheckPermissionResponse) ProtoMessage() {} + +func (x *CheckPermissionResponse) ProtoReflect() protoreflect.Message { + mi := &file_auth_proto_msgTypes[3] + 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 CheckPermissionResponse.ProtoReflect.Descriptor instead. +func (*CheckPermissionResponse) Descriptor() ([]byte, []int) { + return file_auth_proto_rawDescGZIP(), []int{3} +} + +func (x *CheckPermissionResponse) GetAllowed() bool { + if x != nil { + return x.Allowed + } + return false +} + +func (x *CheckPermissionResponse) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *CheckPermissionResponse) GetRoles() []string { + if x != nil { + return x.Roles + } + return nil +} + +var File_auth_proto protoreflect.FileDescriptor + +const file_auth_proto_rawDesc = "" + + "\n" + + "\n" + + "auth.proto\x12\x04auth\",\n" + + "\x14ValidateTokenRequest\x12\x14\n" + + "\x05token\x18\x01 \x01(\tR\x05token\"\xcf\x01\n" + + "\x15ValidateTokenResponse\x12\x14\n" + + "\x05valid\x18\x01 \x01(\bR\x05valid\x12\x17\n" + + "\auser_id\x18\x02 \x01(\tR\x06userId\x12\x1a\n" + + "\busername\x18\x03 \x01(\tR\busername\x12\x14\n" + + "\x05email\x18\x04 \x01(\tR\x05email\x12\x14\n" + + "\x05roles\x18\x05 \x03(\tR\x05roles\x12 \n" + + "\vpermissions\x18\x06 \x03(\tR\vpermissions\x12\x1d\n" + + "\n" + + "expires_at\x18\a \x01(\x03R\texpiresAt\"N\n" + + "\x16CheckPermissionRequest\x12\x14\n" + + "\x05token\x18\x01 \x01(\tR\x05token\x12\x1e\n" + + "\n" + + "permission\x18\x02 \x01(\tR\n" + + "permission\"b\n" + + "\x17CheckPermissionResponse\x12\x18\n" + + "\aallowed\x18\x01 \x01(\bR\aallowed\x12\x17\n" + + "\auser_id\x18\x02 \x01(\tR\x06userId\x12\x14\n" + + "\x05roles\x18\x03 \x03(\tR\x05roles2\xa7\x01\n" + + "\vAuthService\x12H\n" + + "\rValidateToken\x12\x1a.auth.ValidateTokenRequest\x1a\x1b.auth.ValidateTokenResponse\x12N\n" + + "\x0fCheckPermission\x12\x1c.auth.CheckPermissionRequest\x1a\x1d.auth.CheckPermissionResponseB`\n" + + "\bcom.authB\tAuthProtoP\x01Z\x19rag/file-system/api/proto\xa2\x02\x03AXX\xaa\x02\x04Auth\xca\x02\x04Auth\xe2\x02\x10Auth\\GPBMetadata\xea\x02\x04Authb\x06proto3" + +var ( + file_auth_proto_rawDescOnce sync.Once + file_auth_proto_rawDescData []byte +) + +func file_auth_proto_rawDescGZIP() []byte { + file_auth_proto_rawDescOnce.Do(func() { + file_auth_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_auth_proto_rawDesc), len(file_auth_proto_rawDesc))) + }) + return file_auth_proto_rawDescData +} + +var file_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_auth_proto_goTypes = []any{ + (*ValidateTokenRequest)(nil), // 0: auth.ValidateTokenRequest + (*ValidateTokenResponse)(nil), // 1: auth.ValidateTokenResponse + (*CheckPermissionRequest)(nil), // 2: auth.CheckPermissionRequest + (*CheckPermissionResponse)(nil), // 3: auth.CheckPermissionResponse +} +var file_auth_proto_depIdxs = []int32{ + 0, // 0: auth.AuthService.ValidateToken:input_type -> auth.ValidateTokenRequest + 2, // 1: auth.AuthService.CheckPermission:input_type -> auth.CheckPermissionRequest + 1, // 2: auth.AuthService.ValidateToken:output_type -> auth.ValidateTokenResponse + 3, // 3: auth.AuthService.CheckPermission:output_type -> auth.CheckPermissionResponse + 2, // [2:4] is the sub-list for method output_type + 0, // [0:2] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_auth_proto_init() } +func file_auth_proto_init() { + if File_auth_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_auth_proto_rawDesc), len(file_auth_proto_rawDesc)), + NumEnums: 0, + NumMessages: 4, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_auth_proto_goTypes, + DependencyIndexes: file_auth_proto_depIdxs, + MessageInfos: file_auth_proto_msgTypes, + }.Build() + File_auth_proto = out.File + file_auth_proto_goTypes = nil + file_auth_proto_depIdxs = nil +} diff --git a/api/proto/auth.proto b/api/proto/auth.proto new file mode 100644 index 0000000..d98a960 --- /dev/null +++ b/api/proto/auth.proto @@ -0,0 +1,35 @@ +syntax = "proto3"; + +package auth; + +option go_package = "rag/file-system/api/proto"; + +service AuthService { + rpc ValidateToken (ValidateTokenRequest) returns (ValidateTokenResponse); + rpc CheckPermission (CheckPermissionRequest) returns (CheckPermissionResponse); +} + +message ValidateTokenRequest { + string token = 1; +} + +message ValidateTokenResponse { + bool valid = 1; + string user_id = 2; + string username = 3; + string email = 4; + repeated string roles = 5; + repeated string permissions = 6; + int64 expires_at = 7; +} + +message CheckPermissionRequest { + string token = 1; + string permission = 2; +} + +message CheckPermissionResponse { + bool allowed = 1; + string user_id = 2; + repeated string roles = 3; +} diff --git a/api/proto/auth_grpc.pb.go b/api/proto/auth_grpc.pb.go new file mode 100644 index 0000000..05e1859 --- /dev/null +++ b/api/proto/auth_grpc.pb.go @@ -0,0 +1,159 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.1 +// - protoc (unknown) +// source: auth.proto + +package proto + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + AuthService_ValidateToken_FullMethodName = "/auth.AuthService/ValidateToken" + AuthService_CheckPermission_FullMethodName = "/auth.AuthService/CheckPermission" +) + +// AuthServiceClient is the client API for AuthService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type AuthServiceClient interface { + ValidateToken(ctx context.Context, in *ValidateTokenRequest, opts ...grpc.CallOption) (*ValidateTokenResponse, error) + CheckPermission(ctx context.Context, in *CheckPermissionRequest, opts ...grpc.CallOption) (*CheckPermissionResponse, error) +} + +type authServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewAuthServiceClient(cc grpc.ClientConnInterface) AuthServiceClient { + return &authServiceClient{cc} +} + +func (c *authServiceClient) ValidateToken(ctx context.Context, in *ValidateTokenRequest, opts ...grpc.CallOption) (*ValidateTokenResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ValidateTokenResponse) + err := c.cc.Invoke(ctx, AuthService_ValidateToken_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *authServiceClient) CheckPermission(ctx context.Context, in *CheckPermissionRequest, opts ...grpc.CallOption) (*CheckPermissionResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CheckPermissionResponse) + err := c.cc.Invoke(ctx, AuthService_CheckPermission_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// AuthServiceServer is the server API for AuthService service. +// All implementations must embed UnimplementedAuthServiceServer +// for forward compatibility. +type AuthServiceServer interface { + ValidateToken(context.Context, *ValidateTokenRequest) (*ValidateTokenResponse, error) + CheckPermission(context.Context, *CheckPermissionRequest) (*CheckPermissionResponse, error) + mustEmbedUnimplementedAuthServiceServer() +} + +// UnimplementedAuthServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedAuthServiceServer struct{} + +func (UnimplementedAuthServiceServer) ValidateToken(context.Context, *ValidateTokenRequest) (*ValidateTokenResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ValidateToken not implemented") +} +func (UnimplementedAuthServiceServer) CheckPermission(context.Context, *CheckPermissionRequest) (*CheckPermissionResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CheckPermission not implemented") +} +func (UnimplementedAuthServiceServer) mustEmbedUnimplementedAuthServiceServer() {} +func (UnimplementedAuthServiceServer) testEmbeddedByValue() {} + +// UnsafeAuthServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to AuthServiceServer will +// result in compilation errors. +type UnsafeAuthServiceServer interface { + mustEmbedUnimplementedAuthServiceServer() +} + +func RegisterAuthServiceServer(s grpc.ServiceRegistrar, srv AuthServiceServer) { + // If the following call panics, it indicates UnimplementedAuthServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&AuthService_ServiceDesc, srv) +} + +func _AuthService_ValidateToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ValidateTokenRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthServiceServer).ValidateToken(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthService_ValidateToken_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthServiceServer).ValidateToken(ctx, req.(*ValidateTokenRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _AuthService_CheckPermission_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CheckPermissionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AuthServiceServer).CheckPermission(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AuthService_CheckPermission_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AuthServiceServer).CheckPermission(ctx, req.(*CheckPermissionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// AuthService_ServiceDesc is the grpc.ServiceDesc for AuthService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var AuthService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "auth.AuthService", + HandlerType: (*AuthServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "ValidateToken", + Handler: _AuthService_ValidateToken_Handler, + }, + { + MethodName: "CheckPermission", + Handler: _AuthService_CheckPermission_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "auth.proto", +} diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 0000000..3ae62c0 --- /dev/null +++ b/buf.gen.yaml @@ -0,0 +1,13 @@ +version: v2 +managed: + enabled: true + override: + - file_option: go_package_prefix + value: rag/file-system/api/proto +plugins: + - remote: buf.build/protocolbuffers/go + out: api/proto + opt: paths=source_relative + - remote: buf.build/grpc/go + out: api/proto + opt: paths=source_relative diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 0000000..63c42df --- /dev/null +++ b/buf.yaml @@ -0,0 +1,3 @@ +version: v2 +modules: + - path: api/proto diff --git a/internal/api/endpoints/auth_endpoint.go b/internal/api/endpoints/auth_endpoint.go new file mode 100644 index 0000000..4a8cb48 --- /dev/null +++ b/internal/api/endpoints/auth_endpoint.go @@ -0,0 +1,48 @@ +package endpoints + +import ( + "rag/file-system/internal/api/handlers" + "rag/file-system/internal/infrastructure/mediator" + "net/http" + + "github.com/gin-gonic/gin" +) + +// AuthEndpoint handles authentication endpoints +type AuthEndpoint struct { + mediator *mediator.Mediator +} + +// NewAuthEndpoint creates a new auth endpoint +func NewAuthEndpoint(m *mediator.Mediator) *AuthEndpoint { + return &AuthEndpoint{ + mediator: m, + } +} + +// Login authenticates a user with API key +// @Summary User login +// @Description Authenticate with API key +// @Tags Authentication +// @Accept json +// @Produce json +// @Param request body handlers.LoginQuery true "Login credentials" +// @Success 200 {object} handlers.LoginResult +// @Failure 400 {object} map[string]string "Invalid request" +// @Failure 500 {object} map[string]string "Internal error" +// @Router /auth/login [post] +func (e *AuthEndpoint) Login(c *gin.Context) { + var query handlers.LoginQuery + if err := c.ShouldBindJSON(&query); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request parameters"}) + return + } + + result, err := mediator.Send[handlers.LoginQuery, handlers.LoginResult](e.mediator, c.Request.Context(), query) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "authentication failed"}) + return + } + + c.JSON(http.StatusOK, result) +} diff --git a/internal/api/endpoints/auth_endpoints.go b/internal/api/endpoints/auth_endpoints.go deleted file mode 100644 index 38a8e85..0000000 --- a/internal/api/endpoints/auth_endpoints.go +++ /dev/null @@ -1,45 +0,0 @@ -package endpoints - -import ( - "file-system/internal/api/handlers" - "file-system/internal/infrastructure/mediator" - - "github.com/gin-gonic/gin" -) - -// AuthEndpoint 认证端点 -type AuthEndpoint struct { - mediator *mediator.Mediator -} - -// NewAuthEndpoint 创建认证端点 -func NewAuthEndpoint(m *mediator.Mediator) *AuthEndpoint { - return &AuthEndpoint{ - mediator: m, - } -} - -// Login 用户登录 -// @Summary 用户登录 -// @Description 使用 API 密钥登录 -// @Tags 认证 -// @Accept json -// @Produce json -// @Param request body handlers.LoginQuery true "登录信息" -// @Success 200 {object} handlers.LoginResult -// @Router /auth/login [post] -func (e *AuthEndpoint) Login(c *gin.Context) { - var query handlers.LoginQuery - if err := c.ShouldBindJSON(&query); err != nil { - c.JSON(400, gin.H{"error": "请求参数错误"}) - return - } - - result, err := mediator.Send[handlers.LoginQuery, handlers.LoginResult](e.mediator, c.Request.Context(), query) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - - c.JSON(200, result) -} diff --git a/internal/api/endpoints/bucket_endpoint.go b/internal/api/endpoints/bucket_endpoint.go index 09671dd..b8d1121 100644 --- a/internal/api/endpoints/bucket_endpoint.go +++ b/internal/api/endpoints/bucket_endpoint.go @@ -1,11 +1,10 @@ package endpoints import ( - "file-system/internal/api/handlers" - "file-system/internal/api/requests" - "file-system/internal/api/validators" - "file-system/internal/common" - "file-system/internal/infrastructure/mediator" + "rag/file-system/internal/api/handlers" + "rag/file-system/internal/api/requests" + "rag/file-system/internal/api/validators" + "rag/file-system/internal/infrastructure/mediator" "net/http" "github.com/gin-gonic/gin" @@ -24,15 +23,16 @@ func NewBucketEndpoint(m *mediator.Mediator, cbv *validators.CreateBucketValidat } // CreateBucket godoc -// @Summary 创建存储桶 -// @Description 创建一个新的 S3 存储桶 -// @Tags 存储桶管理 +// @Summary Create bucket +// @Description Create a new S3 bucket +// @Tags Bucket Management // @Accept json // @Produce json -// @Param request body requests.CreateBucketRequest true "创建存储桶请求参数" -// @Success 200 {object} map[string]string "创建成功消息" -// @Failure 400 {object} map[string]string "参数错误" -// @Failure 500 {object} map[string]string "服务器内部错误" +// @Security ApiKeyAuth +// @Param request body requests.CreateBucketRequest true "Bucket creation parameters" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string // @Router /buckets [post] func (e *BucketEndpoint) CreateBucket(c *gin.Context) { var req requests.CreateBucketRequest @@ -50,11 +50,7 @@ func (e *BucketEndpoint) CreateBucket(c *gin.Context) { result, err := mediator.Send[handlers.CreateBucketCommand, string](e.Mediator, c.Request.Context(), cmd) if err != nil { - if be, ok := err.(*common.BusinessException); ok { - c.JSON(be.Code, gin.H{"error": be.Message}) - } else { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - } + handleError(c, err) return } @@ -62,34 +58,36 @@ func (e *BucketEndpoint) CreateBucket(c *gin.Context) { } // ListBuckets godoc -// @Summary 获取存储桶列表 -// @Description 列出所有可用的 S3 存储桶 -// @Tags 存储桶管理 +// @Summary List buckets +// @Description List all available S3 buckets +// @Tags Bucket Management // @Accept json // @Produce json -// @Success 200 {object} map[string][]string "存储桶列表" -// @Failure 500 {object} map[string]string "服务器内部错误" +// @Security ApiKeyAuth +// @Success 200 {object} map[string][]string +// @Failure 500 {object} map[string]string // @Router /buckets [get] func (e *BucketEndpoint) ListBuckets(c *gin.Context) { query := handlers.ListBucketsQuery{} result, err := mediator.Send[handlers.ListBucketsQuery, []string](e.Mediator, c.Request.Context(), query) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + handleError(c, err) return } c.JSON(http.StatusOK, gin.H{"buckets": result}) } // DeleteBucket godoc -// @Summary 删除存储桶 -// @Description 删除指定的 S3 存储桶(桶必须为空) -// @Tags 存储桶管理 +// @Summary Delete bucket +// @Description Delete an S3 bucket (must be empty) +// @Tags Bucket Management // @Accept json // @Produce json -// @Param request body requests.DeleteBucketRequest true "删除存储桶请求参数" -// @Success 200 {object} map[string]string "删除成功消息" -// @Failure 400 {object} map[string]string "参数错误" -// @Failure 500 {object} map[string]string "服务器内部错误" +// @Security ApiKeyAuth +// @Param request body requests.DeleteBucketRequest true "Bucket deletion parameters" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string // @Router /buckets [delete] func (e *BucketEndpoint) DeleteBucket(c *gin.Context) { var req requests.DeleteBucketRequest @@ -107,11 +105,7 @@ func (e *BucketEndpoint) DeleteBucket(c *gin.Context) { result, err := mediator.Send[handlers.DeleteBucketCommand, string](e.Mediator, c.Request.Context(), cmd) if err != nil { - if be, ok := err.(*common.BusinessException); ok { - c.JSON(be.Code, gin.H{"error": be.Message}) - } else { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - } + handleError(c, err) return } diff --git a/internal/api/endpoints/common.go b/internal/api/endpoints/common.go new file mode 100644 index 0000000..82324ca --- /dev/null +++ b/internal/api/endpoints/common.go @@ -0,0 +1,21 @@ +package endpoints + +import ( + "rag/file-system/internal/common" + "net/http" + + "github.com/gin-gonic/gin" +) + +func handleError(c *gin.Context, err error) { + if be, ok := err.(*common.BusinessException); ok { + c.JSON(be.Code, gin.H{"error": be.Message}) + } else { + common.Logger.Error("unhandled error", + "error", err, + "path", c.Request.URL.Path, + "request_id", c.GetString("request_id"), + ) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) + } +} diff --git a/internal/api/endpoints/file_endpoint.go b/internal/api/endpoints/file_endpoint.go index 6bccfe8..baa0266 100644 --- a/internal/api/endpoints/file_endpoint.go +++ b/internal/api/endpoints/file_endpoint.go @@ -1,12 +1,13 @@ package endpoints import ( - "file-system/internal/api/handlers" - "file-system/internal/api/requests" - "file-system/internal/api/validators" - "file-system/internal/common" - "file-system/internal/domain/repository" - "file-system/internal/infrastructure/mediator" + "rag/file-system/internal/api/handlers" + "rag/file-system/internal/api/requests" + "rag/file-system/internal/api/validators" + "rag/file-system/internal/common" + "rag/file-system/internal/domain/repository" + "rag/file-system/internal/infrastructure/mediator" + "fmt" "io" "net/http" "time" @@ -15,50 +16,43 @@ import ( ) type FileEndpoint struct { - Mediator *mediator.Mediator - UploadValidator *validators.UploadFileValidator - DownloadValidator *validators.DownloadFileValidator - NewFeaturesValidator *validators.NewFeaturesValidator + Mediator *mediator.Mediator + FileValidator *validators.FileValidator } -func NewFileEndpoint(m *mediator.Mediator, uv *validators.UploadFileValidator, dv *validators.DownloadFileValidator, nfv *validators.NewFeaturesValidator) *FileEndpoint { +func NewFileEndpoint(m *mediator.Mediator, fv *validators.FileValidator) *FileEndpoint { return &FileEndpoint{ - Mediator: m, - UploadValidator: uv, - DownloadValidator: dv, - NewFeaturesValidator: nfv, + Mediator: m, + FileValidator: fv, } } // UploadFile godoc -// @Summary 上传文件 (简单上传) -// @Description 上传小文件到指定的存储桶,支持 multipart/form-data 格式 -// @Tags 文件操作 +// @Summary Upload file +// @Description Upload a small file to the specified bucket +// @Tags Files // @Accept multipart/form-data // @Produce json // @Security ApiKeyAuth -// @Param bucket_name formData string true "存储桶名称" -// @Param file formData file true "要上传的文件" -// @Success 200 {object} map[string]string "上传成功消息" -// @Failure 400 {object} map[string]string "参数错误" -// @Failure 401 {object} map[string]string "未授权:API 密钥无效或缺失" -// @Failure 500 {object} map[string]string "服务器内部错误" +// @Param bucket_name formData string true "Bucket name" +// @Param file formData file true "File to upload" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 500 {object} map[string]string // @Router /files/upload [post] func (e *FileEndpoint) UploadFile(c *gin.Context) { var req requests.UploadFileRequest - // 绑定参数 if err := c.ShouldBind(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - // 验证参数 - if err := e.UploadValidator.Validate(&req); err != nil { + if err := e.FileValidator.ValidateUpload(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - // 打开文件流 file, err := req.File.Open() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open file"}) @@ -66,14 +60,12 @@ func (e *FileEndpoint) UploadFile(c *gin.Context) { } defer file.Close() - // 构建 Command cmd := handlers.UploadFileCommand{ BucketName: req.BucketName, FileName: req.File.Filename, Data: file, } - // 调用 Mediator result, err := mediator.Send[handlers.UploadFileCommand, string](e.Mediator, c.Request.Context(), cmd) if err != nil { handleError(c, err) @@ -84,18 +76,18 @@ func (e *FileEndpoint) UploadFile(c *gin.Context) { } // DownloadFile godoc -// @Summary 下载文件 -// @Description 从指定的存储桶下载文件,返回文件流 -// @Tags 文件操作 +// @Summary Download file +// @Description Download a file from the specified bucket +// @Tags Files // @Accept json // @Produce octet-stream // @Security ApiKeyAuth -// @Param bucket_name query string true "存储桶名称" -// @Param object_key query string true "对象键(文件名)" -// @Success 200 {file} file "文件流" -// @Failure 400 {object} map[string]string "参数错误" -// @Failure 401 {object} map[string]string "未授权:API 密钥无效或缺失" -// @Failure 500 {object} map[string]string "服务器内部错误" +// @Param bucket_name query string true "Bucket name" +// @Param object_key query string true "Object key" +// @Success 200 {file} file +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 500 {object} map[string]string // @Router /files/download [get] func (e *FileEndpoint) DownloadFile(c *gin.Context) { var req requests.DownloadFileRequest @@ -104,7 +96,7 @@ func (e *FileEndpoint) DownloadFile(c *gin.Context) { return } - if err := e.DownloadValidator.Validate(&req); err != nil { + if err := e.FileValidator.ValidateDownload(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -121,26 +113,26 @@ func (e *FileEndpoint) DownloadFile(c *gin.Context) { } defer result.Close() - c.Header("Content-Disposition", "attachment; filename="+req.ObjectKey) + c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, common.SanitizeFilename(req.ObjectKey))) c.Header("Content-Type", "application/octet-stream") io.Copy(c.Writer, result) } // ListFiles godoc -// @Summary 文件列表 (分页) -// @Description 分页查询存储桶中的文件,支持前缀筛选和分页 -// @Tags 文件操作 +// @Summary List files (paginated) +// @Description List files in a bucket with pagination and prefix filtering +// @Tags Files // @Accept json // @Produce json // @Security ApiKeyAuth -// @Param bucket_name query string true "存储桶名称" -// @Param prefix query string false "文件名前缀筛选" -// @Param max_keys query int false "每页数量(默认20)" -// @Param token query string false "分页Token(下一页的凭证)" +// @Param bucket_name query string true "Bucket name" +// @Param prefix query string false "File name prefix filter" +// @Param max_keys query int false "Items per page (default 20)" +// @Param token query string false "Pagination token" // @Success 200 {object} repository.ListFilesResult -// @Failure 400 {object} map[string]string "参数错误" -// @Failure 401 {object} map[string]string "未授权:API 密钥无效或缺失" -// @Failure 500 {object} map[string]string "服务器内部错误" +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 500 {object} map[string]string // @Router /files/list [get] func (e *FileEndpoint) ListFiles(c *gin.Context) { var req requests.ListFilesRequest @@ -149,7 +141,7 @@ func (e *FileEndpoint) ListFiles(c *gin.Context) { return } - if err := e.NewFeaturesValidator.ValidateListFiles(&req); err != nil { + if err := e.FileValidator.ValidateListFiles(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -175,18 +167,18 @@ func (e *FileEndpoint) ListFiles(c *gin.Context) { } // GetPreviewURL godoc -// @Summary 获取预览链接 -// @Description 生成文件的临时预览链接(24小时有效),支持图片/视频/文档等 -// @Tags 文件操作 +// @Summary Get preview URL +// @Description Generate a temporary presigned URL for file preview (24h expiry) +// @Tags Files // @Accept json // @Produce json // @Security ApiKeyAuth -// @Param bucket_name query string true "存储桶名称" -// @Param object_key query string true "对象键(文件名)" -// @Success 200 {object} map[string]string "返回预览 URL" -// @Failure 400 {object} map[string]string "参数错误" -// @Failure 401 {object} map[string]string "未授权:API 密钥无效或缺失" -// @Failure 500 {object} map[string]string "服务器内部错误" +// @Param bucket_name query string true "Bucket name" +// @Param object_key query string true "Object key" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 500 {object} map[string]string // @Router /files/preview [get] func (e *FileEndpoint) GetPreviewURL(c *gin.Context) { var req requests.GetFilePreviewRequest @@ -195,7 +187,7 @@ func (e *FileEndpoint) GetPreviewURL(c *gin.Context) { return } - if err := e.NewFeaturesValidator.ValidatePreview(&req); err != nil { + if err := e.FileValidator.ValidatePreview(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -215,27 +207,27 @@ func (e *FileEndpoint) GetPreviewURL(c *gin.Context) { } // GetFileContent godoc -// @Summary 获取文件文本内容 -// @Description 读取文件的文本内容,用于 Markdown 等文本文件的在线预览 -// @Tags 文件操作 +// @Summary Get file text content +// @Description Retrieve text content of a file for preview (e.g., Markdown) +// @Tags Files // @Accept json // @Produce json // @Security ApiKeyAuth -// @Param bucket_name query string true "存储桶名称" -// @Param object_key query string true "对象键(文件名)" -// @Success 200 {object} map[string]string "返回文件文本内容" -// @Failure 400 {object} map[string]string "参数错误" -// @Failure 401 {object} map[string]string "未授权:API 密钥无效或缺失" -// @Failure 500 {object} map[string]string "服务器内部错误" +// @Param bucket_name query string true "Bucket name" +// @Param object_key query string true "Object key" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 500 {object} map[string]string // @Router /files/content [get] func (e *FileEndpoint) GetFileContent(c *gin.Context) { - var req requests.GetFilePreviewRequest + var req requests.GetFileContentRequest if err := c.ShouldBind(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := e.NewFeaturesValidator.ValidatePreview(&req); err != nil { + if err := e.FileValidator.ValidateGetContent(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -254,17 +246,17 @@ func (e *FileEndpoint) GetFileContent(c *gin.Context) { } // InitMultipart godoc -// @Summary 初始化分片上传 -// @Description 开始一个新的大文件分片上传任务,返回 upload_id 用于后续分片上传 -// @Tags 大文件上传 +// @Summary Initialize multipart upload +// @Description Start a new multipart upload session and return upload_id +// @Tags Multipart Upload // @Accept json // @Produce json // @Security ApiKeyAuth -// @Param request body requests.InitMultipartRequest true "请求参数" -// @Success 200 {object} map[string]string "返回 upload_id" -// @Failure 400 {object} map[string]string "参数错误" -// @Failure 401 {object} map[string]string "未授权:API 密钥无效或缺失" -// @Failure 500 {object} map[string]string "服务器内部错误" +// @Param request body requests.InitMultipartRequest true "Request parameters" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 500 {object} map[string]string // @Router /files/multipart/init [post] func (e *FileEndpoint) InitMultipart(c *gin.Context) { var req requests.InitMultipartRequest @@ -272,7 +264,7 @@ func (e *FileEndpoint) InitMultipart(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := e.NewFeaturesValidator.ValidateInitMultipart(&req); err != nil { + if err := e.FileValidator.ValidateInitMultipart(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -287,21 +279,21 @@ func (e *FileEndpoint) InitMultipart(c *gin.Context) { } // UploadPart godoc -// @Summary 上传分片 -// @Description 上传单个文件分片,建议每个分片 5MB,支持失败重试 -// @Tags 大文件上传 +// @Summary Upload a part +// @Description Upload a single part of a multipart upload (5MB recommended per part) +// @Tags Multipart Upload // @Accept multipart/form-data // @Produce json // @Security ApiKeyAuth -// @Param bucket_name formData string true "存储桶名称" -// @Param object_key formData string true "对象键" -// @Param upload_id formData string true "上传ID(由初始化接口返回)" -// @Param part_number formData int true "分片序号(从1开始)" -// @Param file formData file true "分片文件数据" -// @Success 200 {object} map[string]string "返回 ETag" -// @Failure 400 {object} map[string]string "参数错误" -// @Failure 401 {object} map[string]string "未授权:API 密钥无效或缺失" -// @Failure 500 {object} map[string]string "服务器内部错误" +// @Param bucket_name formData string true "Bucket name" +// @Param object_key formData string true "Object key" +// @Param upload_id formData string true "Upload ID" +// @Param part_number formData int true "Part number (starting from 1)" +// @Param file formData file true "Part data" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 500 {object} map[string]string // @Router /files/multipart/part [put] func (e *FileEndpoint) UploadPart(c *gin.Context) { var req requests.UploadPartRequest @@ -309,7 +301,7 @@ func (e *FileEndpoint) UploadPart(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := e.NewFeaturesValidator.ValidateUploadPart(&req); err != nil { + if err := e.FileValidator.ValidateUploadPart(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -337,17 +329,17 @@ func (e *FileEndpoint) UploadPart(c *gin.Context) { } // CompleteMultipart godoc -// @Summary 完成分片上传 -// @Description 合并所有分片完成上传,需传入所有分片的 PartNumber 和 ETag -// @Tags 大文件上传 +// @Summary Complete multipart upload +// @Description Assemble all parts to complete the upload +// @Tags Multipart Upload // @Accept json // @Produce json // @Security ApiKeyAuth -// @Param request body requests.CompleteMultipartRequest true "请求参数" -// @Success 200 {object} map[string]string "返回文件位置" -// @Failure 400 {object} map[string]string "参数错误" -// @Failure 401 {object} map[string]string "未授权:API 密钥无效或缺失" -// @Failure 500 {object} map[string]string "服务器内部错误" +// @Param request body requests.CompleteMultipartRequest true "Request parameters" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 500 {object} map[string]string // @Router /files/multipart/complete [post] func (e *FileEndpoint) CompleteMultipart(c *gin.Context) { var req requests.CompleteMultipartRequest @@ -355,7 +347,7 @@ func (e *FileEndpoint) CompleteMultipart(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := e.NewFeaturesValidator.ValidateCompleteMultipart(&req); err != nil { + if err := e.FileValidator.ValidateCompleteMultipart(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -374,18 +366,55 @@ func (e *FileEndpoint) CompleteMultipart(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"location": result}) } -// DeleteFile godoc -// @Summary 删除文件 -// @Description 从指定的存储桶删除文件,此操作不可恢复 -// @Tags 文件操作 +// AbortMultipart godoc +// @Summary Abort multipart upload +// @Description Cancel an in-progress multipart upload +// @Tags Multipart Upload // @Accept json // @Produce json // @Security ApiKeyAuth -// @Param request body requests.DeleteFileRequest true "请求参数" -// @Success 200 {object} map[string]string "删除成功消息" -// @Failure 400 {object} map[string]string "参数错误" -// @Failure 401 {object} map[string]string "未授权:API 密钥无效或缺失" -// @Failure 500 {object} map[string]string "服务器内部错误" +// @Param request body requests.AbortMultipartRequest true "Request parameters" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /files/multipart/abort [post] +func (e *FileEndpoint) AbortMultipart(c *gin.Context) { + var req requests.AbortMultipartRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := e.FileValidator.ValidateAbortMultipart(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + cmd := handlers.AbortMultipartCommand{ + BucketName: req.BucketName, + ObjectKey: req.ObjectKey, + UploadId: req.UploadId, + } + result, err := mediator.Send[handlers.AbortMultipartCommand, string](e.Mediator, c.Request.Context(), cmd) + if err != nil { + handleError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"message": result}) +} + +// DeleteFile godoc +// @Summary Delete file +// @Description Delete a file from the specified bucket (irreversible) +// @Tags Files +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param request body requests.DeleteFileRequest true "Request parameters" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 500 {object} map[string]string // @Router /files/delete [delete] func (e *FileEndpoint) DeleteFile(c *gin.Context) { var req requests.DeleteFileRequest @@ -394,7 +423,7 @@ func (e *FileEndpoint) DeleteFile(c *gin.Context) { return } - if err := e.NewFeaturesValidator.ValidateDeleteFile(&req); err != nil { + if err := e.FileValidator.ValidateDeleteFile(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -411,11 +440,3 @@ func (e *FileEndpoint) DeleteFile(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"message": result}) } - -func handleError(c *gin.Context, err error) { - if be, ok := err.(*common.BusinessException); ok { - c.JSON(be.Code, gin.H{"error": be.Message}) - } else { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - } -} diff --git a/internal/api/handlers/bucket_handlers.go b/internal/api/handlers/bucket_handlers.go index a4f7e7d..e00d1d5 100644 --- a/internal/api/handlers/bucket_handlers.go +++ b/internal/api/handlers/bucket_handlers.go @@ -2,7 +2,7 @@ package handlers import ( "context" - "file-system/internal/domain/repository" + "rag/file-system/internal/domain/repository" ) type CreateBucketHandler struct { diff --git a/internal/api/handlers/download_file_handler.go b/internal/api/handlers/download_file_handler.go deleted file mode 100644 index 1fd69c9..0000000 --- a/internal/api/handlers/download_file_handler.go +++ /dev/null @@ -1,19 +0,0 @@ -package handlers - -import ( - "context" - "file-system/internal/domain/repository" - "io" -) - -type DownloadFileHandler struct { - Repo repository.FileRepository -} - -func NewDownloadFileHandler(repo repository.FileRepository) *DownloadFileHandler { - return &DownloadFileHandler{Repo: repo} -} - -func (h *DownloadFileHandler) Handle(ctx context.Context, query DownloadFileQuery) (io.ReadCloser, error) { - return h.Repo.DownloadFile(ctx, query.BucketName, query.ObjectKey) -} diff --git a/internal/api/handlers/download_file_query.go b/internal/api/handlers/download_file_query.go deleted file mode 100644 index 015abcc..0000000 --- a/internal/api/handlers/download_file_query.go +++ /dev/null @@ -1,6 +0,0 @@ -package handlers - -type DownloadFileQuery struct { - BucketName string - ObjectKey string -} diff --git a/internal/api/handlers/file_commands.go b/internal/api/handlers/file_commands.go new file mode 100644 index 0000000..d19589d --- /dev/null +++ b/internal/api/handlers/file_commands.go @@ -0,0 +1,44 @@ +package handlers + +import ( + "io" + + "rag/file-system/internal/common" +) + +type UploadFileCommand struct { + BucketName string + FileName string + Data io.Reader +} + +type DeleteFileCommand struct { + BucketName string + ObjectKey string +} + +type InitMultipartCommand struct { + BucketName string + ObjectKey string +} + +type UploadPartCommand struct { + BucketName string + ObjectKey string + UploadId string + PartNumber int32 + Data io.Reader +} + +type CompleteMultipartCommand struct { + BucketName string + ObjectKey string + UploadId string + Parts []common.Part +} + +type AbortMultipartCommand struct { + BucketName string + ObjectKey string + UploadId string +} diff --git a/internal/api/handlers/file_handlers.go b/internal/api/handlers/file_handlers.go new file mode 100644 index 0000000..dcf8b8f --- /dev/null +++ b/internal/api/handlers/file_handlers.go @@ -0,0 +1,140 @@ +package handlers + +import ( + "context" + "io" + + "rag/file-system/internal/domain/repository" +) + +type UploadFileHandler struct { + Repo repository.FileRepository +} + +func NewUploadFileHandler(repo repository.FileRepository) *UploadFileHandler { + return &UploadFileHandler{Repo: repo} +} + +func (h *UploadFileHandler) Handle(ctx context.Context, cmd UploadFileCommand) (string, error) { + err := h.Repo.UploadFile(ctx, cmd.BucketName, cmd.FileName, cmd.Data) + if err != nil { + return "", err + } + return "File uploaded successfully", nil +} + +type DownloadFileHandler struct { + Repo repository.FileRepository +} + +func NewDownloadFileHandler(repo repository.FileRepository) *DownloadFileHandler { + return &DownloadFileHandler{Repo: repo} +} + +func (h *DownloadFileHandler) Handle(ctx context.Context, query DownloadFileQuery) (io.ReadCloser, error) { + return h.Repo.DownloadFile(ctx, query.BucketName, query.ObjectKey) +} + +type ListFilesHandler struct { + Repo repository.FileRepository +} + +func NewListFilesHandler(repo repository.FileRepository) *ListFilesHandler { + return &ListFilesHandler{Repo: repo} +} + +func (h *ListFilesHandler) Handle(ctx context.Context, q ListFilesQuery) (*repository.ListFilesResult, error) { + return h.Repo.ListObjectsV2(ctx, q.BucketName, q.Prefix, q.MaxKeys, q.Token) +} + +type GetFilePreviewHandler struct { + Repo repository.FileRepository +} + +func NewGetFilePreviewHandler(repo repository.FileRepository) *GetFilePreviewHandler { + return &GetFilePreviewHandler{Repo: repo} +} + +func (h *GetFilePreviewHandler) Handle(ctx context.Context, q GetFilePreviewQuery) (string, error) { + return h.Repo.GeneratePresignedURL(ctx, q.BucketName, q.ObjectKey, q.Expiry) +} + +type GetFileContentHandler struct { + Repo repository.FileRepository +} + +func NewGetFileContentHandler(repo repository.FileRepository) *GetFileContentHandler { + return &GetFileContentHandler{Repo: repo} +} + +func (h *GetFileContentHandler) Handle(ctx context.Context, q GetFileContentQuery) (string, error) { + return h.Repo.GetFileContent(ctx, q.BucketName, q.ObjectKey) +} + +type DeleteFileHandler struct { + Repo repository.FileRepository +} + +func NewDeleteFileHandler(repo repository.FileRepository) *DeleteFileHandler { + return &DeleteFileHandler{Repo: repo} +} + +func (h *DeleteFileHandler) Handle(ctx context.Context, cmd DeleteFileCommand) (string, error) { + err := h.Repo.DeleteFile(ctx, cmd.BucketName, cmd.ObjectKey) + if err != nil { + return "", err + } + return "File deleted successfully", nil +} + +type InitMultipartHandler struct { + Repo repository.FileRepository +} + +func NewInitMultipartHandler(repo repository.FileRepository) *InitMultipartHandler { + return &InitMultipartHandler{Repo: repo} +} + +func (h *InitMultipartHandler) Handle(ctx context.Context, cmd InitMultipartCommand) (string, error) { + return h.Repo.CreateMultipartUpload(ctx, cmd.BucketName, cmd.ObjectKey) +} + +type UploadPartHandler struct { + Repo repository.FileRepository +} + +func NewUploadPartHandler(repo repository.FileRepository) *UploadPartHandler { + return &UploadPartHandler{Repo: repo} +} + +func (h *UploadPartHandler) Handle(ctx context.Context, cmd UploadPartCommand) (string, error) { + return h.Repo.UploadPart(ctx, cmd.BucketName, cmd.ObjectKey, cmd.UploadId, cmd.PartNumber, cmd.Data) +} + +type CompleteMultipartHandler struct { + Repo repository.FileRepository +} + +func NewCompleteMultipartHandler(repo repository.FileRepository) *CompleteMultipartHandler { + return &CompleteMultipartHandler{Repo: repo} +} + +func (h *CompleteMultipartHandler) Handle(ctx context.Context, cmd CompleteMultipartCommand) (string, error) { + return h.Repo.CompleteMultipartUpload(ctx, cmd.BucketName, cmd.ObjectKey, cmd.UploadId, cmd.Parts) +} + +type AbortMultipartHandler struct { + Repo repository.FileRepository +} + +func NewAbortMultipartHandler(repo repository.FileRepository) *AbortMultipartHandler { + return &AbortMultipartHandler{Repo: repo} +} + +func (h *AbortMultipartHandler) Handle(ctx context.Context, cmd AbortMultipartCommand) (string, error) { + err := h.Repo.AbortMultipartUpload(ctx, cmd.BucketName, cmd.ObjectKey, cmd.UploadId) + if err != nil { + return "", err + } + return "Multipart upload aborted successfully", nil +} diff --git a/internal/api/handlers/file_queries.go b/internal/api/handlers/file_queries.go new file mode 100644 index 0000000..76ac5c7 --- /dev/null +++ b/internal/api/handlers/file_queries.go @@ -0,0 +1,26 @@ +package handlers + +import "time" + +type DownloadFileQuery struct { + BucketName string + ObjectKey string +} + +type ListFilesQuery struct { + BucketName string + Prefix string + MaxKeys int32 + Token *string +} + +type GetFilePreviewQuery struct { + BucketName string + ObjectKey string + Expiry time.Duration +} + +type GetFileContentQuery struct { + BucketName string + ObjectKey string +} diff --git a/internal/api/handlers/multipart_handlers_split.go b/internal/api/handlers/multipart_handlers_split.go deleted file mode 100644 index 1c1c57f..0000000 --- a/internal/api/handlers/multipart_handlers_split.go +++ /dev/null @@ -1,45 +0,0 @@ -package handlers - -import ( - "context" - "file-system/internal/domain/repository" -) - -// InitMultipartHandler -type InitMultipartHandler struct { - Repo repository.FileRepository -} - -func NewInitMultipartHandler(repo repository.FileRepository) *InitMultipartHandler { - return &InitMultipartHandler{Repo: repo} -} - -func (h *InitMultipartHandler) Handle(ctx context.Context, cmd InitMultipartCommand) (string, error) { - return h.Repo.CreateMultipartUpload(ctx, cmd.BucketName, cmd.ObjectKey) -} - -// UploadPartHandler -type UploadPartHandler struct { - Repo repository.FileRepository -} - -func NewUploadPartHandler(repo repository.FileRepository) *UploadPartHandler { - return &UploadPartHandler{Repo: repo} -} - -func (h *UploadPartHandler) Handle(ctx context.Context, cmd UploadPartCommand) (string, error) { - return h.Repo.UploadPart(ctx, cmd.BucketName, cmd.ObjectKey, cmd.UploadId, cmd.PartNumber, cmd.Data) -} - -// CompleteMultipartHandler -type CompleteMultipartHandler struct { - Repo repository.FileRepository -} - -func NewCompleteMultipartHandler(repo repository.FileRepository) *CompleteMultipartHandler { - return &CompleteMultipartHandler{Repo: repo} -} - -func (h *CompleteMultipartHandler) Handle(ctx context.Context, cmd CompleteMultipartCommand) (string, error) { - return h.Repo.CompleteMultipartUpload(ctx, cmd.BucketName, cmd.ObjectKey, cmd.UploadId, cmd.Parts) -} diff --git a/internal/api/handlers/query_handlers.go b/internal/api/handlers/query_handlers.go deleted file mode 100644 index fd1c548..0000000 --- a/internal/api/handlers/query_handlers.go +++ /dev/null @@ -1,110 +0,0 @@ -package handlers - -import ( - "context" - "file-system/internal/common" - "file-system/internal/domain/repository" - "io" - "time" -) - -// Queries & Commands -type ListFilesQuery struct { - BucketName string - Prefix string - MaxKeys int32 - Token *string -} - -type GetFilePreviewQuery struct { - BucketName string - ObjectKey string - Expiry time.Duration -} - -// GetFileContentQuery 获取文件文本内容查询 -type GetFileContentQuery struct { - BucketName string - ObjectKey string -} - -type InitMultipartCommand struct { - BucketName string - ObjectKey string -} - -type UploadPartCommand struct { - BucketName string - ObjectKey string - UploadId string - PartNumber int32 - Data io.Reader -} - -type CompleteMultipartCommand struct { - BucketName string - ObjectKey string - UploadId string - Parts []common.Part -} - -// Handlers - -type ListFilesHandler struct { - Repo repository.FileRepository -} - -func NewListFilesHandler(repo repository.FileRepository) *ListFilesHandler { - return &ListFilesHandler{Repo: repo} -} - -func (h *ListFilesHandler) Handle(ctx context.Context, q ListFilesQuery) (*repository.ListFilesResult, error) { - return h.Repo.ListObjectsV2(ctx, q.BucketName, q.Prefix, q.MaxKeys, q.Token) -} - -type GetFilePreviewHandler struct { - Repo repository.FileRepository -} - -func NewGetFilePreviewHandler(repo repository.FileRepository) *GetFilePreviewHandler { - return &GetFilePreviewHandler{Repo: repo} -} - -func (h *GetFilePreviewHandler) Handle(ctx context.Context, q GetFilePreviewQuery) (string, error) { - return h.Repo.GeneratePresignedURL(ctx, q.BucketName, q.ObjectKey, q.Expiry) -} - -// GetFileContentHandler 获取文件文本内容处理器 -type GetFileContentHandler struct { - Repo repository.FileRepository -} - -func NewGetFileContentHandler(repo repository.FileRepository) *GetFileContentHandler { - return &GetFileContentHandler{Repo: repo} -} - -func (h *GetFileContentHandler) Handle(ctx context.Context, q GetFileContentQuery) (string, error) { - return h.Repo.GetFileContent(ctx, q.BucketName, q.ObjectKey) -} - -// DeleteFileCommand 删除文件命令 -type DeleteFileCommand struct { - BucketName string - ObjectKey string -} - -type DeleteFileHandler struct { - Repo repository.FileRepository -} - -func NewDeleteFileHandler(repo repository.FileRepository) *DeleteFileHandler { - return &DeleteFileHandler{Repo: repo} -} - -func (h *DeleteFileHandler) Handle(ctx context.Context, cmd DeleteFileCommand) (string, error) { - err := h.Repo.DeleteFile(ctx, cmd.BucketName, cmd.ObjectKey) - if err != nil { - return "", err - } - return "File deleted successfully", nil -} diff --git a/internal/api/handlers/upload_file_command.go b/internal/api/handlers/upload_file_command.go deleted file mode 100644 index 256c759..0000000 --- a/internal/api/handlers/upload_file_command.go +++ /dev/null @@ -1,9 +0,0 @@ -package handlers - -import "io" - -type UploadFileCommand struct { - BucketName string - FileName string - Data io.Reader -} diff --git a/internal/api/handlers/upload_file_handler.go b/internal/api/handlers/upload_file_handler.go deleted file mode 100644 index 0907582..0000000 --- a/internal/api/handlers/upload_file_handler.go +++ /dev/null @@ -1,25 +0,0 @@ -package handlers - -import ( - "context" - "file-system/internal/domain/repository" -) - -type UploadFileHandler struct { - Repo repository.FileRepository -} - -func NewUploadFileHandler(repo repository.FileRepository) *UploadFileHandler { - return &UploadFileHandler{Repo: repo} -} - -func (h *UploadFileHandler) Handle(ctx context.Context, cmd UploadFileCommand) (string, error) { - // 业务逻辑:上传文件 - // 调用 Repository - err := h.Repo.UploadFile(ctx, cmd.BucketName, cmd.FileName, cmd.Data) - if err != nil { - // 简单的错误处理,实际可能需要包装为 BusinessException - return "", err - } - return "File uploaded successfully", nil -} diff --git a/internal/api/requests/download_file_request.go b/internal/api/requests/download_file_request.go deleted file mode 100644 index d2fa56e..0000000 --- a/internal/api/requests/download_file_request.go +++ /dev/null @@ -1,6 +0,0 @@ -package requests - -type DownloadFileRequest struct { - BucketName string `form:"bucket_name"` - ObjectKey string `form:"object_key"` -} diff --git a/internal/api/requests/new_features_requests.go b/internal/api/requests/file_requests.go similarity index 66% rename from internal/api/requests/new_features_requests.go rename to internal/api/requests/file_requests.go index c7cf52d..152360f 100644 --- a/internal/api/requests/new_features_requests.go +++ b/internal/api/requests/file_requests.go @@ -1,8 +1,9 @@ package requests import ( - "file-system/internal/common" "mime/multipart" + + "rag/file-system/internal/common" ) type ListFilesRequest struct { @@ -17,6 +18,11 @@ type GetFilePreviewRequest struct { ObjectKey string `form:"object_key"` } +type GetFileContentRequest struct { + BucketName string `form:"bucket_name"` + ObjectKey string `form:"object_key"` +} + type InitMultipartRequest struct { BucketName string `json:"bucket_name"` ObjectKey string `json:"object_key"` @@ -41,3 +47,19 @@ type DeleteFileRequest struct { BucketName string `json:"bucket_name"` ObjectKey string `json:"object_key"` } + +type AbortMultipartRequest struct { + BucketName string `json:"bucket_name"` + ObjectKey string `json:"object_key"` + UploadId string `json:"upload_id"` +} + +type DownloadFileRequest struct { + BucketName string `form:"bucket_name"` + ObjectKey string `form:"object_key"` +} + +type UploadFileRequest struct { + BucketName string `form:"bucket_name"` + File *multipart.FileHeader `form:"file"` +} diff --git a/internal/api/requests/upload_file_request.go b/internal/api/requests/upload_file_request.go deleted file mode 100644 index 1966670..0000000 --- a/internal/api/requests/upload_file_request.go +++ /dev/null @@ -1,8 +0,0 @@ -package requests - -import "mime/multipart" - -type UploadFileRequest struct { - BucketName string `form:"bucket_name"` - File *multipart.FileHeader `form:"file"` -} diff --git a/internal/api/validators/bucket_validators.go b/internal/api/validators/bucket_validators.go index 8fec143..5e134f8 100644 --- a/internal/api/validators/bucket_validators.go +++ b/internal/api/validators/bucket_validators.go @@ -1,8 +1,8 @@ package validators import ( - "file-system/internal/api/requests" - "file-system/internal/common" + "rag/file-system/internal/api/requests" + "rag/file-system/internal/common" ) type CreateBucketValidator struct{} @@ -15,6 +15,9 @@ func (v *CreateBucketValidator) Validate(req *requests.CreateBucketRequest) erro if req.BucketName == "" { return common.NewBusinessException("Bucket name cannot be empty") } + if err := common.SanitizeBucketName(req.BucketName); err != nil { + return err + } return nil } @@ -22,5 +25,8 @@ func (v *CreateBucketValidator) ValidateDelete(req *requests.DeleteBucketRequest if req.BucketName == "" { return common.NewBusinessException("Bucket name cannot be empty") } + if err := common.SanitizeBucketName(req.BucketName); err != nil { + return err + } return nil } diff --git a/internal/api/validators/download_file_validator.go b/internal/api/validators/download_file_validator.go deleted file mode 100644 index db31469..0000000 --- a/internal/api/validators/download_file_validator.go +++ /dev/null @@ -1,22 +0,0 @@ -package validators - -import ( - "file-system/internal/api/requests" - "file-system/internal/common" -) - -type DownloadFileValidator struct{} - -func NewDownloadFileValidator() *DownloadFileValidator { - return &DownloadFileValidator{} -} - -func (v *DownloadFileValidator) Validate(req *requests.DownloadFileRequest) error { - if req.BucketName == "" { - return common.NewBusinessException("Bucket name cannot be empty") - } - if req.ObjectKey == "" { - return common.NewBusinessException("Object key cannot be empty") - } - return nil -} diff --git a/internal/api/validators/file_validators.go b/internal/api/validators/file_validators.go new file mode 100644 index 0000000..56bcec1 --- /dev/null +++ b/internal/api/validators/file_validators.go @@ -0,0 +1,97 @@ +package validators + +import ( + "rag/file-system/internal/api/requests" + "rag/file-system/internal/common" +) + +type FileValidator struct{} + +func NewFileValidator() *FileValidator { + return &FileValidator{} +} + +func (v *FileValidator) ValidateListFiles(req *requests.ListFilesRequest) error { + if req.BucketName == "" { + return common.NewBusinessException("Bucket name is required") + } + if err := common.SanitizeBucketName(req.BucketName); err != nil { + return err + } + if req.MaxKeys <= 0 { + req.MaxKeys = 20 + } + if req.MaxKeys > 1000 { + req.MaxKeys = 1000 + } + return nil +} + +func (v *FileValidator) ValidatePreview(req *requests.GetFilePreviewRequest) error { + if req.BucketName == "" || req.ObjectKey == "" { + return common.NewBusinessException("Bucket name and Object key are required") + } + return common.SanitizeObjectKey(req.ObjectKey) +} + +func (v *FileValidator) ValidateGetContent(req *requests.GetFileContentRequest) error { + if req.BucketName == "" || req.ObjectKey == "" { + return common.NewBusinessException("Bucket name and Object key are required") + } + return common.SanitizeObjectKey(req.ObjectKey) +} + +func (v *FileValidator) ValidateInitMultipart(req *requests.InitMultipartRequest) error { + if req.BucketName == "" || req.ObjectKey == "" { + return common.NewBusinessException("Bucket name and Object key are required") + } + return common.SanitizeObjectKey(req.ObjectKey) +} + +func (v *FileValidator) ValidateUploadPart(req *requests.UploadPartRequest) error { + if req.BucketName == "" || req.ObjectKey == "" || req.UploadId == "" || req.PartNumber <= 0 || req.File == nil { + return common.NewBusinessException("Missing required fields for upload part") + } + return common.SanitizeObjectKey(req.ObjectKey) +} + +func (v *FileValidator) ValidateCompleteMultipart(req *requests.CompleteMultipartRequest) error { + if req.BucketName == "" || req.ObjectKey == "" || req.UploadId == "" || len(req.Parts) == 0 { + return common.NewBusinessException("Missing required fields for completion") + } + return common.SanitizeObjectKey(req.ObjectKey) +} + +func (v *FileValidator) ValidateDeleteFile(req *requests.DeleteFileRequest) error { + if req.BucketName == "" || req.ObjectKey == "" { + return common.NewBusinessException("Bucket name and Object key are required") + } + return common.SanitizeObjectKey(req.ObjectKey) +} + +func (v *FileValidator) ValidateAbortMultipart(req *requests.AbortMultipartRequest) error { + if req.BucketName == "" || req.ObjectKey == "" || req.UploadId == "" { + return common.NewBusinessException("Bucket name, Object key, and Upload ID are required") + } + return common.SanitizeObjectKey(req.ObjectKey) +} + +func (v *FileValidator) ValidateDownload(req *requests.DownloadFileRequest) error { + if req.BucketName == "" { + return common.NewBusinessException("Bucket name cannot be empty") + } + if req.ObjectKey == "" { + return common.NewBusinessException("Object key cannot be empty") + } + return common.SanitizeObjectKey(req.ObjectKey) +} + +func (v *FileValidator) ValidateUpload(req *requests.UploadFileRequest) error { + if req.BucketName == "" { + return common.NewBusinessException("Bucket name cannot be empty") + } + if req.File == nil { + return common.NewBusinessException("File is required") + } + return common.SanitizeObjectKey(req.File.Filename) +} diff --git a/internal/api/validators/new_features_validators.go b/internal/api/validators/new_features_validators.go deleted file mode 100644 index eeebfd7..0000000 --- a/internal/api/validators/new_features_validators.go +++ /dev/null @@ -1,57 +0,0 @@ -package validators - -import ( - "file-system/internal/api/requests" - "file-system/internal/common" -) - -type NewFeaturesValidator struct{} - -func NewNewFeaturesValidator() *NewFeaturesValidator { - return &NewFeaturesValidator{} -} - -func (v *NewFeaturesValidator) ValidateListFiles(req *requests.ListFilesRequest) error { - if req.BucketName == "" { - return common.NewBusinessException("Bucket name is required") - } - if req.MaxKeys <= 0 { - req.MaxKeys = 10 // default - } - return nil -} - -func (v *NewFeaturesValidator) ValidatePreview(req *requests.GetFilePreviewRequest) error { - if req.BucketName == "" || req.ObjectKey == "" { - return common.NewBusinessException("Bucket name and Object key are required") - } - return nil -} - -func (v *NewFeaturesValidator) ValidateInitMultipart(req *requests.InitMultipartRequest) error { - if req.BucketName == "" || req.ObjectKey == "" { - return common.NewBusinessException("Bucket name and Object key are required") - } - return nil -} - -func (v *NewFeaturesValidator) ValidateUploadPart(req *requests.UploadPartRequest) error { - if req.BucketName == "" || req.ObjectKey == "" || req.UploadId == "" || req.PartNumber <= 0 || req.File == nil { - return common.NewBusinessException("Missing required fields for upload part") - } - return nil -} - -func (v *NewFeaturesValidator) ValidateCompleteMultipart(req *requests.CompleteMultipartRequest) error { - if req.BucketName == "" || req.ObjectKey == "" || req.UploadId == "" || len(req.Parts) == 0 { - return common.NewBusinessException("Missing required fields for completion") - } - return nil -} - -func (v *NewFeaturesValidator) ValidateDeleteFile(req *requests.DeleteFileRequest) error { - if req.BucketName == "" || req.ObjectKey == "" { - return common.NewBusinessException("Bucket name and Object key are required") - } - return nil -} diff --git a/internal/api/validators/upload_file_validator.go b/internal/api/validators/upload_file_validator.go deleted file mode 100644 index d2ea205..0000000 --- a/internal/api/validators/upload_file_validator.go +++ /dev/null @@ -1,22 +0,0 @@ -package validators - -import ( - "file-system/internal/api/requests" - "file-system/internal/common" -) - -type UploadFileValidator struct{} - -func NewUploadFileValidator() *UploadFileValidator { - return &UploadFileValidator{} -} - -func (v *UploadFileValidator) Validate(req *requests.UploadFileRequest) error { - if req.BucketName == "" { - return common.NewBusinessException("Bucket name cannot be empty") - } - if req.File == nil { - return common.NewBusinessException("File is required") - } - return nil -} diff --git a/internal/common/config_test.go b/internal/common/config_test.go new file mode 100644 index 0000000..1514f43 --- /dev/null +++ b/internal/common/config_test.go @@ -0,0 +1,37 @@ +package common + +import "testing" + +func TestConfig_Validate_MissingAuthAPIKey(t *testing.T) { + cfg := &Config{ + RustFSAccessKeyID: "key", + RustFSSecretAccessKey: "secret", + RustFSEndpoint: "http://localhost:9000", + } + if err := cfg.Validate(); err == nil { + t.Error("expected error when AuthAPIKey is empty, got nil") + } +} + +func TestConfig_Validate_MissingRustFSAccessKeyID(t *testing.T) { + cfg := &Config{ + AuthAPIKey: "api-key", + RustFSSecretAccessKey: "secret", + RustFSEndpoint: "http://localhost:9000", + } + if err := cfg.Validate(); err == nil { + t.Error("expected error when RustFSAccessKeyID is empty, got nil") + } +} + +func TestConfig_Validate_AllFieldsPresent(t *testing.T) { + cfg := &Config{ + AuthAPIKey: "api-key", + RustFSAccessKeyID: "access-key", + RustFSSecretAccessKey: "secret-key", + RustFSEndpoint: "http://localhost:9000", + } + if err := cfg.Validate(); err != nil { + t.Errorf("expected nil when all fields present, got error: %v", err) + } +} diff --git a/internal/common/errors.go b/internal/common/errors.go index 3d09b0b..00ed2ab 100644 --- a/internal/common/errors.go +++ b/internal/common/errors.go @@ -15,3 +15,24 @@ func NewBusinessException(message string) *BusinessException { Code: 400, } } + +func NewNotFoundError(message string) *BusinessException { + return &BusinessException{ + Message: message, + Code: 404, + } +} + +func NewConflictError(message string) *BusinessException { + return &BusinessException{ + Message: message, + Code: 409, + } +} + +func NewBusinessExceptionWithCode(code int, message string) *BusinessException { + return &BusinessException{ + Message: message, + Code: code, + } +} diff --git a/internal/common/logger.go b/internal/common/logger.go new file mode 100644 index 0000000..3a8322f --- /dev/null +++ b/internal/common/logger.go @@ -0,0 +1,14 @@ +package common + +import ( + "log/slog" + "os" +) + +var Logger *slog.Logger + +func InitLogger() { + Logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) +} diff --git a/internal/common/s3_errors.go b/internal/common/s3_errors.go new file mode 100644 index 0000000..78cb9eb --- /dev/null +++ b/internal/common/s3_errors.go @@ -0,0 +1,26 @@ +package common + +import ( + "errors" + "fmt" + + "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +func WrapS3Error(err error) error { + if err == nil { + return nil + } + + var ( + noSuchBucket *types.NoSuchBucket + noSuchKey *types.NoSuchKey + notFound *types.NotFound + ) + + if errors.As(err, &noSuchBucket) || errors.As(err, &noSuchKey) || errors.As(err, ¬Found) { + return NewNotFoundError("resource not found") + } + + return fmt.Errorf("storage operation failed") +} diff --git a/internal/common/sanitize.go b/internal/common/sanitize.go new file mode 100644 index 0000000..15567e4 --- /dev/null +++ b/internal/common/sanitize.go @@ -0,0 +1,32 @@ +package common + +import ( + "regexp" + "strings" +) + +var bucketNameRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$`) + +func SanitizeObjectKey(key string) error { + if strings.Contains(key, "..") || strings.Contains(key, "//") || strings.HasPrefix(key, "/") { + return NewBusinessException("invalid object key: path traversal detected") + } + return nil +} + +func SanitizeBucketName(name string) error { + if !bucketNameRegex.MatchString(name) { + return NewBusinessException("invalid bucket name: must be 3-63 lowercase letters, digits, hyphens, or dots") + } + if len(name) < 3 || len(name) > 63 { + return NewBusinessException("invalid bucket name: must be between 3 and 63 characters") + } + return nil +} + +func SanitizeFilename(name string) string { + safe := strings.ReplaceAll(name, `"`, `\"`) + safe = strings.ReplaceAll(safe, "\r", "") + safe = strings.ReplaceAll(safe, "\n", "") + return safe +} diff --git a/internal/common/sanitize_test.go b/internal/common/sanitize_test.go new file mode 100644 index 0000000..f62c645 --- /dev/null +++ b/internal/common/sanitize_test.go @@ -0,0 +1,108 @@ +package common + +import ( + "strings" + "testing" +) + +// --------------------------------------------------------------------------- +// SanitizeObjectKey +// --------------------------------------------------------------------------- + +func TestSanitizeObjectKey_ValidInput(t *testing.T) { + keys := []string{"folder/file.txt", "file.csv", "a/b/c/d.json"} + for _, key := range keys { + if err := SanitizeObjectKey(key); err != nil { + t.Errorf("expected nil for key %q, got error: %v", key, err) + } + } +} + +func TestSanitizeObjectKey_PathTraversal(t *testing.T) { + cases := []struct { + name string + key string + }{ + {"double dot", "../etc/passwd"}, + {"double dot middle", "a/../b"}, + {"double slash", "folder//file.txt"}, + {"leading slash", "/absolute/path"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := SanitizeObjectKey(tc.key) + if err == nil { + t.Errorf("expected error for key %q, got nil", tc.key) + } + }) + } +} + +// --------------------------------------------------------------------------- +// SanitizeBucketName +// --------------------------------------------------------------------------- + +func TestSanitizeBucketName_Valid(t *testing.T) { + names := []string{"my-bucket", "bucket123", "a1b", "my.bucket.name"} + for _, name := range names { + if err := SanitizeBucketName(name); err != nil { + t.Errorf("expected nil for bucket %q, got error: %v", name, err) + } + } +} + +func TestSanitizeBucketName_Invalid(t *testing.T) { + cases := []struct { + name string + input string + }{ + {"uppercase", "MyBucket"}, + {"too short", "ab"}, + {"starts with hyphen", "-bucket"}, + {"starts with dot", ".bucket"}, + {"contains underscore", "my_bucket"}, + {"empty", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := SanitizeBucketName(tc.input) + if err == nil { + t.Errorf("expected error for bucket %q, got nil", tc.input) + } + }) + } +} + +func TestSanitizeBucketName_TooLong(t *testing.T) { + longName := strings.Repeat("a", 64) + if err := SanitizeBucketName(longName); err == nil { + t.Error("expected error for bucket name > 63 chars, got nil") + } +} + +// --------------------------------------------------------------------------- +// SanitizeFilename +// --------------------------------------------------------------------------- + +func TestSanitizeFilename_RemovesCRLF(t *testing.T) { + input := "file\r\nname.txt" + got := SanitizeFilename(input) + if strings.Contains(got, "\r") || strings.Contains(got, "\n") { + t.Errorf("expected \\r and \\n removed, got %q", got) + } +} + +func TestSanitizeFilename_EscapesQuotes(t *testing.T) { + input := `some"file.txt` + got := SanitizeFilename(input) + if strings.Contains(got, `"`) && !strings.Contains(got, `\"`) { + t.Errorf("expected quotes escaped, got %q", got) + } +} + +func TestSanitizeFilename_CleanInput(t *testing.T) { + input := "clean-file.txt" + if got := SanitizeFilename(input); got != input { + t.Errorf("expected %q, got %q", input, got) + } +} diff --git a/internal/domain/repository/file_repository.go b/internal/domain/repository/file_repository.go index ea758ea..0f865a8 100644 --- a/internal/domain/repository/file_repository.go +++ b/internal/domain/repository/file_repository.go @@ -2,7 +2,7 @@ package repository import ( "context" - "file-system/internal/common" + "rag/file-system/internal/common" "io" "time" ) @@ -25,19 +25,18 @@ type FileRepository interface { ListBuckets(ctx context.Context) ([]string, error) CreateBucket(ctx context.Context, bucketName string) error DeleteBucket(ctx context.Context, bucketName string) error - ListObjects(ctx context.Context, bucketName string) ([]string, error) - // 新增功能 + // File listing with pagination ListObjectsV2(ctx context.Context, bucketName string, prefix string, maxKeys int32, continuationToken *string) (*ListFilesResult, error) GeneratePresignedURL(ctx context.Context, bucketName string, objectKey string, expiry time.Duration) (string, error) - // 获取文件文本内容(用于文本文件预览) + // File content retrieval for text preview GetFileContent(ctx context.Context, bucketName string, objectKey string) (string, error) - // 删除文件 + // File deletion DeleteFile(ctx context.Context, bucketName string, objectKey string) error - // 分片上传 + // Multipart upload CreateMultipartUpload(ctx context.Context, bucketName string, objectKey string) (string, error) UploadPart(ctx context.Context, bucketName string, objectKey string, uploadId string, partNumber int32, data io.Reader) (string, error) CompleteMultipartUpload(ctx context.Context, bucketName string, objectKey string, uploadId string, parts []common.Part) (string, error) diff --git a/internal/infrastructure/grpc/auth_client.go b/internal/infrastructure/grpc/auth_client.go new file mode 100644 index 0000000..c3ea520 --- /dev/null +++ b/internal/infrastructure/grpc/auth_client.go @@ -0,0 +1,101 @@ +package grpc + +import ( + "context" + "fmt" + "rag/file-system/api/proto" + "rag/file-system/internal/common" + "time" + + "github.com/patrickmn/go-cache" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +type TokenInfo struct { + Valid bool + UserId string + Username string + Email string + Roles []string + Permissions []string + ExpiresAt int64 +} + +type AuthClient struct { + conn *grpc.ClientConn + client proto.AuthServiceClient + cache *cache.Cache +} + +func NewAuthClient(addr string) (*AuthClient, error) { + conn, err := grpc.NewClient(addr, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + return nil, fmt.Errorf("gRPC connect failed: %w", err) + } + + common.Logger.Info("gRPC auth client connected", "addr", addr) + + return &AuthClient{ + conn: conn, + client: proto.NewAuthServiceClient(conn), + cache: cache.New(2*time.Minute, 5*time.Minute), + }, nil +} + +func (a *AuthClient) ValidateToken(ctx context.Context, token string) (*TokenInfo, error) { + if cached, ok := a.cache.Get(token); ok { + return cached.(*TokenInfo), nil + } + + resp, err := a.client.ValidateToken(ctx, &proto.ValidateTokenRequest{Token: token}) + if err != nil { + return nil, fmt.Errorf("gRPC ValidateToken failed: %w", err) + } + + info := &TokenInfo{ + Valid: resp.Valid, + UserId: resp.UserId, + Username: resp.Username, + Email: resp.Email, + Roles: resp.Roles, + Permissions: resp.Permissions, + ExpiresAt: resp.ExpiresAt, + } + + if info.Valid { + ttl := a.cacheTTL(resp.ExpiresAt) + a.cache.Set(token, info, ttl) + } + + return info, nil +} + +func (a *AuthClient) HasPermission(token, permission string) (bool, error) { + resp, err := a.client.CheckPermission(context.Background(), &proto.CheckPermissionRequest{ + Token: token, + Permission: permission, + }) + if err != nil { + return false, fmt.Errorf("gRPC CheckPermission failed: %w", err) + } + return resp.Allowed, nil +} + +func (a *AuthClient) Close() error { + return a.conn.Close() +} + +func (a *AuthClient) cacheTTL(expiresAt int64) time.Duration { + expires := time.Unix(expiresAt, 0) + ttl := time.Until(expires) - 30*time.Second + if ttl < time.Minute { + ttl = time.Minute + } + if ttl > 2*time.Minute { + ttl = 2 * time.Minute + } + return ttl +} diff --git a/internal/infrastructure/mediator/mediator_test.go b/internal/infrastructure/mediator/mediator_test.go new file mode 100644 index 0000000..3156a20 --- /dev/null +++ b/internal/infrastructure/mediator/mediator_test.go @@ -0,0 +1,70 @@ +package mediator + +import ( + "context" + "errors" + "testing" +) + +// --- test request / response types --- + +type testRequest struct { + Message string +} + +type testResponse struct { + Result string +} + +// --- stub handler --- + +type stubHandler struct { + response testResponse + err error +} + +func (h *stubHandler) Handle(_ context.Context, _ testRequest) (testResponse, error) { + return h.response, h.err +} + +// --- tests --- + +func TestRegisterAndSend_Success(t *testing.T) { + m := NewMediator() + Register[testRequest, testResponse](m, &stubHandler{ + response: testResponse{Result: "ok"}, + }) + + resp, err := Send[testRequest, testResponse](m, context.Background(), testRequest{Message: "hello"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Result != "ok" { + t.Errorf("expected result %q, got %q", "ok", resp.Result) + } +} + +func TestSend_UnregisteredType(t *testing.T) { + m := NewMediator() + + _, err := Send[testRequest, testResponse](m, context.Background(), testRequest{Message: "hello"}) + if err == nil { + t.Fatal("expected error for unregistered type, got nil") + } +} + +func TestSend_HandlerError(t *testing.T) { + m := NewMediator() + expectedErr := errors.New("something went wrong") + Register[testRequest, testResponse](m, &stubHandler{ + err: expectedErr, + }) + + _, err := Send[testRequest, testResponse](m, context.Background(), testRequest{Message: "hello"}) + if err == nil { + t.Fatal("expected error from handler, got nil") + } + if err.Error() != expectedErr.Error() { + t.Errorf("expected error %q, got %q", expectedErr.Error(), err.Error()) + } +} diff --git a/internal/infrastructure/s3/client.go b/internal/infrastructure/s3/client.go index 1652614..d0919e8 100644 --- a/internal/infrastructure/s3/client.go +++ b/internal/infrastructure/s3/client.go @@ -2,7 +2,7 @@ package s3 import ( "context" - "file-system/internal/common" + "rag/file-system/internal/common" "log" "github.com/aws/aws-sdk-go-v2/aws" @@ -12,12 +12,11 @@ import ( ) type RustFSClient struct { - Client *s3.Client - PresignClient *s3.PresignClient + client *s3.Client + presignClient *s3.PresignClient } func NewRustFSClient(cfg *common.Config) *RustFSClient { - // Custom Endpoint Resolver customResolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) { return aws.Endpoint{ URL: cfg.RustFSEndpoint, @@ -43,7 +42,15 @@ func NewRustFSClient(cfg *common.Config) *RustFSClient { }) return &RustFSClient{ - Client: client, - PresignClient: s3.NewPresignClient(client), + client: client, + presignClient: s3.NewPresignClient(client), } } + +func (c *RustFSClient) S3Client() *s3.Client { + return c.client +} + +func (c *RustFSClient) PresignClient() *s3.PresignClient { + return c.presignClient +} diff --git a/internal/infrastructure/s3/file_repository_impl.go b/internal/infrastructure/s3/file_repository_impl.go index 57c24ac..d6713fe 100644 --- a/internal/infrastructure/s3/file_repository_impl.go +++ b/internal/infrastructure/s3/file_repository_impl.go @@ -2,8 +2,8 @@ package s3 import ( "context" - "file-system/internal/common" - "file-system/internal/domain/repository" + "rag/file-system/internal/common" + "rag/file-system/internal/domain/repository" "io" "sort" "time" @@ -13,6 +13,8 @@ import ( "github.com/aws/aws-sdk-go-v2/service/s3/types" ) +const maxContentPreviewSize = 10 * 1024 * 1024 // 10MB + type S3FileRepository struct { client *RustFSClient } @@ -22,29 +24,29 @@ func NewS3FileRepository(client *RustFSClient) repository.FileRepository { } func (r *S3FileRepository) UploadFile(ctx context.Context, bucketName string, objectKey string, data io.Reader) error { - _, err := r.client.Client.PutObject(ctx, &s3.PutObjectInput{ + _, err := r.client.S3Client().PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), Body: data, }) - return err + return common.WrapS3Error(err) } func (r *S3FileRepository) DownloadFile(ctx context.Context, bucketName string, objectKey string) (io.ReadCloser, error) { - resp, err := r.client.Client.GetObject(ctx, &s3.GetObjectInput{ + resp, err := r.client.S3Client().GetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), }) if err != nil { - return nil, err + return nil, common.WrapS3Error(err) } return resp.Body, nil } func (r *S3FileRepository) ListBuckets(ctx context.Context) ([]string, error) { - resp, err := r.client.Client.ListBuckets(ctx, &s3.ListBucketsInput{}) + resp, err := r.client.S3Client().ListBuckets(ctx, &s3.ListBucketsInput{}) if err != nil { - return nil, err + return nil, common.WrapS3Error(err) } var buckets []string for _, b := range resp.Buckets { @@ -56,62 +58,50 @@ func (r *S3FileRepository) ListBuckets(ctx context.Context) ([]string, error) { } func (r *S3FileRepository) CreateBucket(ctx context.Context, bucketName string) error { - _, err := r.client.Client.CreateBucket(ctx, &s3.CreateBucketInput{ + _, err := r.client.S3Client().CreateBucket(ctx, &s3.CreateBucketInput{ Bucket: aws.String(bucketName), }) - return err + return common.WrapS3Error(err) } func (r *S3FileRepository) DeleteBucket(ctx context.Context, bucketName string) error { - _, err := r.client.Client.DeleteBucket(ctx, &s3.DeleteBucketInput{ + _, err := r.client.S3Client().DeleteBucket(ctx, &s3.DeleteBucketInput{ Bucket: aws.String(bucketName), }) - return err + return common.WrapS3Error(err) } -func (r *S3FileRepository) ListObjects(ctx context.Context, bucketName string) ([]string, error) { - resp, err := r.client.Client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ - Bucket: aws.String(bucketName), - }) - if err != nil { - return nil, err - } - var objects []string - for _, obj := range resp.Contents { - if obj.Key != nil { - objects = append(objects, *obj.Key) - } - } - return objects, nil -} - -// GetFileContent 获取文件文本内容(用于 Markdown 等文本文件预览) +// GetFileContent retrieves text file content for preview (e.g., Markdown files) func (r *S3FileRepository) GetFileContent(ctx context.Context, bucketName string, objectKey string) (string, error) { - resp, err := r.client.Client.GetObject(ctx, &s3.GetObjectInput{ + resp, err := r.client.S3Client().GetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), }) if err != nil { - return "", err + return "", common.WrapS3Error(err) } defer resp.Body.Close() - data, err := io.ReadAll(resp.Body) + + data, err := io.ReadAll(io.LimitReader(resp.Body, maxContentPreviewSize)) if err != nil { return "", err } + if int64(len(data)) >= maxContentPreviewSize { + return "", common.NewBusinessException("file too large for content preview (max 10MB)") + } return string(data), nil } -// DeleteFile 删除文件 +// DeleteFile removes a file from the bucket func (r *S3FileRepository) DeleteFile(ctx context.Context, bucketName string, objectKey string) error { - _, err := r.client.Client.DeleteObject(ctx, &s3.DeleteObjectInput{ + _, err := r.client.S3Client().DeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), }) - return err + return common.WrapS3Error(err) } -// ListObjectsV2 分页列出文件 +// ListObjectsV2 lists files with pagination support func (r *S3FileRepository) ListObjectsV2(ctx context.Context, bucketName string, prefix string, maxKeys int32, continuationToken *string) (*repository.ListFilesResult, error) { input := &s3.ListObjectsV2Input{ Bucket: aws.String(bucketName), @@ -122,13 +112,16 @@ func (r *S3FileRepository) ListObjectsV2(ctx context.Context, bucketName string, input.ContinuationToken = continuationToken } - resp, err := r.client.Client.ListObjectsV2(ctx, input) + resp, err := r.client.S3Client().ListObjectsV2(ctx, input) if err != nil { - return nil, err + return nil, common.WrapS3Error(err) } files := make([]repository.FileInfo, 0, len(resp.Contents)) for _, obj := range resp.Contents { + if obj.Key == nil || obj.Size == nil || obj.LastModified == nil || obj.ETag == nil { + continue + } files = append(files, repository.FileInfo{ Key: *obj.Key, Size: *obj.Size, @@ -143,35 +136,38 @@ func (r *S3FileRepository) ListObjectsV2(ctx context.Context, bucketName string, }, nil } -// GeneratePresignedURL 生成预签名链接 +// GeneratePresignedURL generates a presigned URL for temporary file access func (r *S3FileRepository) GeneratePresignedURL(ctx context.Context, bucketName string, objectKey string, expiry time.Duration) (string, error) { - presignResult, err := r.client.PresignClient.PresignGetObject(ctx, &s3.GetObjectInput{ + presignResult, err := r.client.PresignClient().PresignGetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), }, func(opts *s3.PresignOptions) { opts.Expires = expiry }) if err != nil { - return "", err + return "", common.WrapS3Error(err) } return presignResult.URL, nil } -// CreateMultipartUpload 初始化分片上传 +// CreateMultipartUpload initializes a multipart upload session func (r *S3FileRepository) CreateMultipartUpload(ctx context.Context, bucketName string, objectKey string) (string, error) { - resp, err := r.client.Client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{ + resp, err := r.client.S3Client().CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), }) if err != nil { - return "", err + return "", common.WrapS3Error(err) + } + if resp.UploadId == nil { + return "", common.NewBusinessException("failed to initialize multipart upload") } return *resp.UploadId, nil } -// UploadPart 上传分片 +// UploadPart uploads a single part of a multipart upload func (r *S3FileRepository) UploadPart(ctx context.Context, bucketName string, objectKey string, uploadId string, partNumber int32, data io.Reader) (string, error) { - resp, err := r.client.Client.UploadPart(ctx, &s3.UploadPartInput{ + resp, err := r.client.S3Client().UploadPart(ctx, &s3.UploadPartInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), UploadId: aws.String(uploadId), @@ -179,14 +175,16 @@ func (r *S3FileRepository) UploadPart(ctx context.Context, bucketName string, ob Body: data, }) if err != nil { - return "", err + return "", common.WrapS3Error(err) + } + if resp.ETag == nil { + return "", common.NewBusinessException("failed to upload part") } return *resp.ETag, nil } -// CompleteMultipartUpload 完成分片上传 +// CompleteMultipartUpload assembles all parts to complete the upload func (r *S3FileRepository) CompleteMultipartUpload(ctx context.Context, bucketName string, objectKey string, uploadId string, parts []common.Part) (string, error) { - // 需要按 PartNumber 排序 sort.Slice(parts, func(i, j int) bool { return parts[i].PartNumber < parts[j].PartNumber }) @@ -199,7 +197,7 @@ func (r *S3FileRepository) CompleteMultipartUpload(ctx context.Context, bucketNa } } - resp, err := r.client.Client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{ + resp, err := r.client.S3Client().CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), UploadId: aws.String(uploadId), @@ -208,17 +206,20 @@ func (r *S3FileRepository) CompleteMultipartUpload(ctx context.Context, bucketNa }, }) if err != nil { - return "", err + return "", common.WrapS3Error(err) + } + if resp.Location == nil { + return "", common.NewBusinessException("failed to complete multipart upload") } return *resp.Location, nil } -// AbortMultipartUpload 取消分片上传 +// AbortMultipartUpload cancels an in-progress multipart upload func (r *S3FileRepository) AbortMultipartUpload(ctx context.Context, bucketName string, objectKey string, uploadId string) error { - _, err := r.client.Client.AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{ + _, err := r.client.S3Client().AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), UploadId: aws.String(uploadId), }) - return err + return common.WrapS3Error(err) } diff --git a/internal/middleware/auth_test.go b/internal/middleware/auth_test.go new file mode 100644 index 0000000..3932554 --- /dev/null +++ b/internal/middleware/auth_test.go @@ -0,0 +1,63 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func TestAuthMiddleware_MissingKey(t *testing.T) { + r := gin.New() + r.Use(AuthMiddleware("secret-key")) + r.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +func TestAuthMiddleware_WrongKey(t *testing.T) { + r := gin.New() + r.Use(AuthMiddleware("secret-key")) + r.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("X-API-Key", "wrong-key") + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +func TestAuthMiddleware_CorrectKey(t *testing.T) { + r := gin.New() + r.Use(AuthMiddleware("secret-key")) + r.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("X-API-Key", "secret-key") + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} diff --git a/internal/middleware/jwt_auth.go b/internal/middleware/jwt_auth.go new file mode 100644 index 0000000..87e04fe --- /dev/null +++ b/internal/middleware/jwt_auth.go @@ -0,0 +1,80 @@ +package middleware + +import ( + "net/http" + "rag/file-system/internal/infrastructure/grpc" + "strings" + + "github.com/gin-gonic/gin" +) + +const ( + HeaderAuthorization = "Authorization" + BearerPrefix = "Bearer " + + ContextKeyUserID = "user_id" + ContextKeyUsername = "username" + ContextKeyEmail = "email" + ContextKeyRoles = "roles" + ContextKeyPermissions = "permissions" +) + +func JWTAuthMiddleware(authClient *grpc.AuthClient) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader(HeaderAuthorization) + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{ + "code": http.StatusUnauthorized, + "message": "未授权:请提供 Bearer Token", + }) + c.Abort() + return + } + + if !strings.HasPrefix(authHeader, BearerPrefix) { + c.JSON(http.StatusUnauthorized, gin.H{ + "code": http.StatusUnauthorized, + "message": "未授权:Token 格式错误,需要 Bearer ", + }) + c.Abort() + return + } + + token := strings.TrimPrefix(authHeader, BearerPrefix) + if token == "" { + c.JSON(http.StatusUnauthorized, gin.H{ + "code": http.StatusUnauthorized, + "message": "未授权:Token 不能为空", + }) + c.Abort() + return + } + + info, err := authClient.ValidateToken(c.Request.Context(), token) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "code": http.StatusUnauthorized, + "message": "Token 验证失败", + }) + c.Abort() + return + } + + if !info.Valid { + c.JSON(http.StatusUnauthorized, gin.H{ + "code": http.StatusUnauthorized, + "message": "Token 无效或已过期", + }) + c.Abort() + return + } + + c.Set(ContextKeyUserID, info.UserId) + c.Set(ContextKeyUsername, info.Username) + c.Set(ContextKeyEmail, info.Email) + c.Set(ContextKeyRoles, info.Roles) + c.Set(ContextKeyPermissions, info.Permissions) + + c.Next() + } +} diff --git a/internal/middleware/logging.go b/internal/middleware/logging.go new file mode 100644 index 0000000..283dba9 --- /dev/null +++ b/internal/middleware/logging.go @@ -0,0 +1,25 @@ +package middleware + +import ( + "rag/file-system/internal/common" + "time" + + "github.com/gin-gonic/gin" +) + +func LoggingMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + duration := time.Since(start) + + common.Logger.Info("request", + "method", c.Request.Method, + "path", c.Request.URL.Path, + "status", c.Writer.Status(), + "duration_ms", duration.Milliseconds(), + "request_id", c.GetString("request_id"), + "client_ip", c.ClientIP(), + ) + } +} diff --git a/internal/middleware/rate_limit.go b/internal/middleware/rate_limit.go new file mode 100644 index 0000000..e7f2ba7 --- /dev/null +++ b/internal/middleware/rate_limit.go @@ -0,0 +1,24 @@ +package middleware + +import ( + "net/http" + "sync" + + "github.com/gin-gonic/gin" + "golang.org/x/time/rate" +) + +func RateLimitMiddleware(rps float64, burst int) gin.HandlerFunc { + var limiters sync.Map + + return func(c *gin.Context) { + key := c.ClientIP() + l, _ := limiters.LoadOrStore(key, rate.NewLimiter(rate.Limit(rps), burst)) + if !l.(*rate.Limiter).Allow() { + c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many requests"}) + c.Abort() + return + } + c.Next() + } +} diff --git a/internal/middleware/request_id.go b/internal/middleware/request_id.go new file mode 100644 index 0000000..a38c978 --- /dev/null +++ b/internal/middleware/request_id.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "crypto/rand" + "encoding/hex" + + "github.com/gin-gonic/gin" +) + +func RequestIDMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + requestID := c.GetHeader("X-Request-ID") + if requestID == "" { + b := make([]byte, 8) + rand.Read(b) + requestID = hex.EncodeToString(b) + } + c.Set("request_id", requestID) + c.Header("X-Request-ID", requestID) + c.Next() + } +} diff --git a/internal/middleware/request_id_test.go b/internal/middleware/request_id_test.go new file mode 100644 index 0000000..363758d --- /dev/null +++ b/internal/middleware/request_id_test.go @@ -0,0 +1,51 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func TestRequestIDMiddleware_GeneratesID(t *testing.T) { + r := gin.New() + r.Use(RequestIDMiddleware()) + r.GET("/test", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/test", nil) + r.ServeHTTP(w, req) + + gotID := w.Header().Get("X-Request-ID") + if gotID == "" { + t.Error("expected X-Request-ID to be generated, got empty string") + } + if len(gotID) != 16 { + t.Errorf("expected 16-char hex ID, got %d chars: %q", len(gotID), gotID) + } +} + +func TestRequestIDMiddleware_PreservesExisting(t *testing.T) { + r := gin.New() + r.Use(RequestIDMiddleware()) + r.GET("/test", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("X-Request-ID", "my-custom-id") + r.ServeHTTP(w, req) + + gotID := w.Header().Get("X-Request-ID") + if gotID != "my-custom-id" { + t.Errorf("expected X-Request-ID to be preserved as %q, got %q", "my-custom-id", gotID) + } +} diff --git a/internal/middleware/timeout.go b/internal/middleware/timeout.go new file mode 100644 index 0000000..7fbe0a8 --- /dev/null +++ b/internal/middleware/timeout.go @@ -0,0 +1,17 @@ +package middleware + +import ( + "context" + "time" + + "github.com/gin-gonic/gin" +) + +func TimeoutMiddleware(timeout time.Duration) gin.HandlerFunc { + return func(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), timeout) + defer cancel() + c.Request = c.Request.WithContext(ctx) + c.Next() + } +} diff --git a/server b/server new file mode 100755 index 0000000..38c1110 Binary files /dev/null and b/server differ diff --git a/web/index.html b/web/index.html deleted file mode 100644 index e4dddd0..0000000 --- a/web/index.html +++ /dev/null @@ -1,606 +0,0 @@ - - - - - - RustFS 高级文件管理系统 - - - - - - - - - - - - - -
- -
-
- -

需要登录

-

请先登录以访问文件管理系统

- - 前往登录 - -
-
- - -
-
-

RustFS 文件管理系统

-
- - 对接指南 - - - API 文档 - - - -
-
- -
- -
-
-
- 存储桶列表 - -
-
- -
- 暂无存储桶 -
-
-
-
- - -
- -
-
-
-
- -
-
-
- - -
-
-
- - -
-
-
-
- - -
-
- - - - - - - - - - - - - - - - - - - - -
文件名大小修改时间操作
- - {{ file.Key }} - {{ formatSize(file.Size) }}{{ formatDate(file.LastModified) }} - - - - - - - - - -
- - 暂无文件 -
-
- - -
- -
- -

请选择一个存储桶

-
- - -
-
上传任务队列
-
    -
  • -
    -
    - {{ upload.file.name }} - {{ upload.status }} -
    - {{ upload.progress }}% -
    -
    -
    -
    -
    {{ upload.error }}
    -
  • -
-
-
-
- - - - - - - -
-
- - - - diff --git a/web/login.html b/web/login.html deleted file mode 100644 index 195d05c..0000000 --- a/web/login.html +++ /dev/null @@ -1,212 +0,0 @@ - - - - - - RustFS - 用户登录 - - - - - - - -
- -
- - - - - -