refactor: commit all pending file_system changes
- Restructure handlers into file_commands/file_queries/file_handlers - Add gRPC auth client, JWT middleware, rate limiting, request ID - Add common utilities: logger, sanitizer, s3_errors - Add unit tests for config, mediator, auth, request_id, sanitize - Add proto definitions and generated code - Remove old web UI pages - Add .dockerignore and .env.example
This commit is contained in:
parent
5e20a6d7fc
commit
b5df6445e5
@ -1 +0,0 @@
|
||||
{"insecure-registries":["192.168.1.154:31010"]}
|
||||
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@ -0,0 +1,10 @@
|
||||
.git
|
||||
.gitignore
|
||||
.idea
|
||||
.vscode
|
||||
docs
|
||||
*.md
|
||||
Jenkinsfile
|
||||
.gitlab-ci.yml
|
||||
.dockerignore
|
||||
%USERPROFILE%.dockerdaemon.json
|
||||
9
.env.example
Normal file
9
.env.example
Normal file
@ -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
|
||||
356
api/proto/auth.pb.go
Normal file
356
api/proto/auth.pb.go
Normal file
@ -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
|
||||
}
|
||||
35
api/proto/auth.proto
Normal file
35
api/proto/auth.proto
Normal file
@ -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;
|
||||
}
|
||||
159
api/proto/auth_grpc.pb.go
Normal file
159
api/proto/auth_grpc.pb.go
Normal file
@ -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",
|
||||
}
|
||||
13
buf.gen.yaml
Normal file
13
buf.gen.yaml
Normal file
@ -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
|
||||
48
internal/api/endpoints/auth_endpoint.go
Normal file
48
internal/api/endpoints/auth_endpoint.go
Normal file
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
21
internal/api/endpoints/common.go
Normal file
21
internal/api/endpoints/common.go
Normal file
@ -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"})
|
||||
}
|
||||
}
|
||||
@ -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()})
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"file-system/internal/domain/repository"
|
||||
"rag/file-system/internal/domain/repository"
|
||||
)
|
||||
|
||||
type CreateBucketHandler struct {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
package handlers
|
||||
|
||||
type DownloadFileQuery struct {
|
||||
BucketName string
|
||||
ObjectKey string
|
||||
}
|
||||
44
internal/api/handlers/file_commands.go
Normal file
44
internal/api/handlers/file_commands.go
Normal file
@ -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
|
||||
}
|
||||
140
internal/api/handlers/file_handlers.go
Normal file
140
internal/api/handlers/file_handlers.go
Normal file
@ -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
|
||||
}
|
||||
26
internal/api/handlers/file_queries.go
Normal file
26
internal/api/handlers/file_queries.go
Normal file
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import "io"
|
||||
|
||||
type UploadFileCommand struct {
|
||||
BucketName string
|
||||
FileName string
|
||||
Data io.Reader
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
package requests
|
||||
|
||||
type DownloadFileRequest struct {
|
||||
BucketName string `form:"bucket_name"`
|
||||
ObjectKey string `form:"object_key"`
|
||||
}
|
||||
@ -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"`
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
package requests
|
||||
|
||||
import "mime/multipart"
|
||||
|
||||
type UploadFileRequest struct {
|
||||
BucketName string `form:"bucket_name"`
|
||||
File *multipart.FileHeader `form:"file"`
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
97
internal/api/validators/file_validators.go
Normal file
97
internal/api/validators/file_validators.go
Normal file
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
37
internal/common/config_test.go
Normal file
37
internal/common/config_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
14
internal/common/logger.go
Normal file
14
internal/common/logger.go
Normal file
@ -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,
|
||||
}))
|
||||
}
|
||||
26
internal/common/s3_errors.go
Normal file
26
internal/common/s3_errors.go
Normal file
@ -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")
|
||||
}
|
||||
32
internal/common/sanitize.go
Normal file
32
internal/common/sanitize.go
Normal file
@ -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
|
||||
}
|
||||
108
internal/common/sanitize_test.go
Normal file
108
internal/common/sanitize_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
101
internal/infrastructure/grpc/auth_client.go
Normal file
101
internal/infrastructure/grpc/auth_client.go
Normal file
@ -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
|
||||
}
|
||||
70
internal/infrastructure/mediator/mediator_test.go
Normal file
70
internal/infrastructure/mediator/mediator_test.go
Normal file
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
63
internal/middleware/auth_test.go
Normal file
63
internal/middleware/auth_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
80
internal/middleware/jwt_auth.go
Normal file
80
internal/middleware/jwt_auth.go
Normal file
@ -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 <token>",
|
||||
})
|
||||
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()
|
||||
}
|
||||
}
|
||||
25
internal/middleware/logging.go
Normal file
25
internal/middleware/logging.go
Normal file
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
24
internal/middleware/rate_limit.go
Normal file
24
internal/middleware/rate_limit.go
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
22
internal/middleware/request_id.go
Normal file
22
internal/middleware/request_id.go
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
51
internal/middleware/request_id_test.go
Normal file
51
internal/middleware/request_id_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
17
internal/middleware/timeout.go
Normal file
17
internal/middleware/timeout.go
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
606
web/index.html
606
web/index.html
@ -1,606 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RustFS 高级文件管理系统</title>
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Vue 3 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/vue@3.3.4/dist/vue.global.prod.js"></script>
|
||||
<!-- Axios -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<!-- Font Awesome -->
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<!-- Marked.js Markdown 渲染 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<style>
|
||||
body { background-color: #f8f9fa; font-family: "Microsoft YaHei", sans-serif; }
|
||||
.card { border-radius: 10px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); border: none; margin-bottom: 20px; }
|
||||
.card-header { background-color: #fff; border-bottom: 1px solid #eee; font-weight: bold; padding: 15px 20px; }
|
||||
.btn-primary { background-color: #0d6efd; border: none; }
|
||||
.progress { height: 20px; border-radius: 10px; }
|
||||
.file-icon { font-size: 1.2rem; margin-right: 10px; color: #6c757d; }
|
||||
.action-btn { cursor: pointer; margin-right: 10px; }
|
||||
.action-btn:hover { color: #0d6efd; }
|
||||
[v-cloak] { display: none; }
|
||||
/* Markdown 预览样式 */
|
||||
.markdown-body { text-align: left; padding: 20px; max-height: 80vh; overflow-y: auto; }
|
||||
.markdown-body h1 { font-size: 1.8rem; border-bottom: 1px solid #eee; padding-bottom: 0.3rem; }
|
||||
.markdown-body h2 { font-size: 1.5rem; border-bottom: 1px solid #eee; padding-bottom: 0.3rem; }
|
||||
.markdown-body h3 { font-size: 1.3rem; }
|
||||
.markdown-body pre { background: #f6f8fa; padding: 16px; border-radius: 6px; overflow-x: auto; }
|
||||
.markdown-body code { background: #f6f8fa; padding: 2px 6px; border-radius: 3px; font-size: 90%; }
|
||||
.markdown-body pre code { background: none; padding: 0; }
|
||||
.markdown-body blockquote { border-left: 4px solid #ddd; padding: 0 15px; color: #666; }
|
||||
.markdown-body table { border-collapse: collapse; width: 100%; margin: 16px 0; }
|
||||
.markdown-body th, .markdown-body td { border: 1px solid #ddd; padding: 8px 12px; }
|
||||
.markdown-body th { background: #f6f8fa; }
|
||||
.markdown-body img { max-width: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="container py-4" v-cloak>
|
||||
<!-- 未登录状态 -->
|
||||
<div v-if="!isAuthenticated" class="text-center py-5">
|
||||
<div class="alert alert-warning d-inline-block">
|
||||
<i class="fas fa-lock fa-3x mb-3 d-block"></i>
|
||||
<h4>需要登录</h4>
|
||||
<p class="mb-3">请先登录以访问文件管理系统</p>
|
||||
<a href="/web/login.html" class="btn btn-primary btn-lg">
|
||||
<i class="fas fa-sign-in-alt me-2"></i>前往登录
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已登录状态 -->
|
||||
<div v-else>
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><i class="fas fa-cloud-upload-alt text-primary me-2"></i>RustFS 文件管理系统</h1>
|
||||
<div>
|
||||
<a href="/web/guide.html" target="_blank" class="btn btn-outline-info me-2">
|
||||
<i class="fas fa-plug me-1"></i>对接指南
|
||||
</a>
|
||||
<a href="/swagger/index.html" target="_blank" class="btn btn-outline-secondary me-2">
|
||||
<i class="fas fa-book me-1"></i>API 文档
|
||||
</a>
|
||||
<button class="btn btn-primary me-2" @click="showCreateBucketModal = true">
|
||||
<i class="fas fa-plus me-1"></i>新建存储桶
|
||||
</button>
|
||||
<button class="btn btn-outline-danger" @click="logout">
|
||||
<i class="fas fa-sign-out-alt me-1"></i>退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Sidebar: Buckets -->
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
存储桶列表
|
||||
<button class="btn btn-sm btn-link" @click="loadBuckets"><i class="fas fa-sync-alt"></i></button>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
<div v-for="bucket in buckets" :key="bucket"
|
||||
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
|
||||
:class="{ active: currentBucket === bucket }">
|
||||
<a href="#" class="text-decoration-none flex-grow-1" :class="{ 'text-white': currentBucket === bucket }" @click.prevent="selectBucket(bucket)">
|
||||
<i class="fas fa-box me-2"></i>{{ bucket }}
|
||||
</a>
|
||||
<span class="action-btn" :class="currentBucket === bucket ? 'text-white' : 'text-danger'" @click.stop="deleteBucket(bucket)" title="删除存储桶">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="buckets.length === 0" class="list-group-item text-muted text-center py-4">
|
||||
暂无存储桶
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content: File List & Upload -->
|
||||
<div class="col-md-9">
|
||||
<!-- Toolbar -->
|
||||
<div class="card mb-3" v-if="currentBucket">
|
||||
<div class="card-body py-3">
|
||||
<div class="row g-3 align-items-center">
|
||||
<div class="col-auto">
|
||||
<label class="col-form-label fw-bold">{{ currentBucket }}</label>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-white"><i class="fas fa-search"></i></span>
|
||||
<input type="text" class="form-control" placeholder="搜索文件名..." v-model="filters.prefix" @keyup.enter="refreshFiles">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-success" @click="triggerFileInput">
|
||||
<i class="fas fa-upload me-1"></i>上传文件
|
||||
</button>
|
||||
<input type="file" ref="fileInput" class="d-none" @change="handleFileSelect">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File List Table -->
|
||||
<div class="card" v-if="currentBucket">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-4">文件名</th>
|
||||
<th>大小</th>
|
||||
<th>修改时间</th>
|
||||
<th class="text-end pe-4">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="file in files" :key="file.Key">
|
||||
<td class="ps-4">
|
||||
<i :class="getFileIcon(file.Key)" class="file-icon"></i>
|
||||
{{ file.Key }}
|
||||
</td>
|
||||
<td>{{ formatSize(file.Size) }}</td>
|
||||
<td class="text-muted small">{{ formatDate(file.LastModified) }}</td>
|
||||
<td class="text-end pe-4">
|
||||
<span class="action-btn text-primary" @click="previewFile(file.Key)" title="预览">
|
||||
<i class="fas fa-eye"></i>
|
||||
</span>
|
||||
<span class="action-btn text-success" @click="downloadFile(file.Key)" title="下载">
|
||||
<i class="fas fa-download"></i>
|
||||
</span>
|
||||
<span class="action-btn text-danger" @click="deleteFile(file.Key)" title="删除">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="files.length === 0 && !loadingFiles">
|
||||
<td colspan="4" class="text-center py-5 text-muted">
|
||||
<i class="fas fa-folder-open fa-3x mb-3 d-block opacity-50"></i>
|
||||
暂无文件
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Pagination -->
|
||||
<div class="card-footer d-flex justify-content-between align-items-center" v-if="nextToken || pageHistory.length > 0">
|
||||
<button class="btn btn-sm btn-outline-secondary" :disabled="pageHistory.length === 0" @click="prevPage">
|
||||
<i class="fas fa-chevron-left me-1"></i>上一页
|
||||
</button>
|
||||
<span class="text-muted small">当前页数: {{ pageHistory.length + 1 }}</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" :disabled="!nextToken" @click="nextPage">
|
||||
下一页<i class="fas fa-chevron-right ms-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-5">
|
||||
<i class="fas fa-arrow-left fa-3x text-muted mb-3 d-block opacity-25"></i>
|
||||
<h4 class="text-muted">请选择一个存储桶</h4>
|
||||
</div>
|
||||
|
||||
<!-- Upload Progress -->
|
||||
<div class="card mt-3" v-if="uploads.length > 0">
|
||||
<div class="card-header">上传任务队列</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item" v-for="upload in uploads" :key="upload.id">
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<div>
|
||||
<span class="fw-bold">{{ upload.file.name }}</span>
|
||||
<span class="badge bg-secondary ms-2">{{ upload.status }}</span>
|
||||
</div>
|
||||
<span class="small text-muted">{{ upload.progress }}%</span>
|
||||
</div>
|
||||
<div class="progress">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
||||
:class="getProgressBarClass(upload.status)"
|
||||
role="progressbar"
|
||||
:style="{ width: upload.progress + '%' }"></div>
|
||||
</div>
|
||||
<div class="mt-1 small text-danger" v-if="upload.error">{{ upload.error }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Bucket Modal -->
|
||||
<div class="modal fade show" style="display: block; background: rgba(0,0,0,0.5)" v-if="showCreateBucketModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">新建存储桶</h5>
|
||||
<button type="button" class="btn-close" @click="showCreateBucketModal = false"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">存储桶名称</label>
|
||||
<input type="text" class="form-control" v-model="newBucketName" placeholder="输入名称...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showCreateBucketModal = false">取消</button>
|
||||
<button type="button" class="btn btn-primary" @click="createBucket">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Modal -->
|
||||
<div class="modal fade show" style="display: block; background: rgba(0,0,0,0.5)" v-if="previewUrl">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">文件预览</h5>
|
||||
<button type="button" class="btn-close" @click="previewUrl = null"></button>
|
||||
</div>
|
||||
<div class="modal-body bg-light p-4">
|
||||
<div v-if="isPreviewMarkdown" class="markdown-body" v-html="markdownHtml"></div>
|
||||
<div v-else class="text-center">
|
||||
<img v-if="isPreviewImage" :src="previewUrl" class="img-fluid" style="max-height: 80vh">
|
||||
<video v-else-if="isPreviewVideo" :src="previewUrl" controls class="w-100" style="max-height: 80vh"></video>
|
||||
<iframe v-else :src="previewUrl" class="w-100" style="height: 60vh; border:none"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer justify-content-center">
|
||||
<a :href="previewUrl" target="_blank" class="btn btn-outline-primary">
|
||||
<i class="fas fa-external-link-alt me-1"></i>在新窗口打开
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp, ref, computed, onMounted } = Vue;
|
||||
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const isAuthenticated = ref(false);
|
||||
const buckets = ref([]);
|
||||
const currentBucket = ref(null);
|
||||
const files = ref([]);
|
||||
const loadingFiles = ref(false);
|
||||
const nextToken = ref(null);
|
||||
const pageHistory = ref([]);
|
||||
const filters = ref({ prefix: '', maxKeys: 20 });
|
||||
|
||||
const showCreateBucketModal = ref(false);
|
||||
const newBucketName = ref('');
|
||||
|
||||
const uploads = ref([]);
|
||||
|
||||
const previewUrl = ref(null);
|
||||
const previewType = ref('');
|
||||
const markdownHtml = ref('');
|
||||
|
||||
// 检查登录状态
|
||||
const checkAuth = () => {
|
||||
const token = localStorage.getItem('rustfs_token');
|
||||
if (token) {
|
||||
isAuthenticated.value = true;
|
||||
initApiClient(token);
|
||||
} else {
|
||||
isAuthenticated.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化 API 客户端
|
||||
let api = null;
|
||||
|
||||
const initApiClient = (token) => {
|
||||
api = axios.create({
|
||||
baseURL: window.location.origin,
|
||||
headers: {
|
||||
'X-API-Key': token
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 退出登录
|
||||
const logout = () => {
|
||||
if (confirm('确定要退出登录吗?')) {
|
||||
localStorage.removeItem('rustfs_token');
|
||||
window.location.href = '/web/login.html';
|
||||
}
|
||||
};
|
||||
|
||||
// Load Buckets
|
||||
const loadBuckets = async () => {
|
||||
try {
|
||||
const res = await api.get('/buckets');
|
||||
buckets.value = res.data.buckets || [];
|
||||
} catch (err) {
|
||||
if (err.response?.status === 401) {
|
||||
alert('登录已过期,请重新登录');
|
||||
localStorage.removeItem('rustfs_token');
|
||||
window.location.href = '/web/login.html';
|
||||
} else {
|
||||
alert('加载存储桶失败: ' + (err.response?.data?.error || err.message));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create Bucket
|
||||
const createBucket = async () => {
|
||||
if (!newBucketName.value) return alert('请输入名称');
|
||||
try {
|
||||
await api.post('/buckets', { bucket_name: newBucketName.value });
|
||||
await loadBuckets();
|
||||
showCreateBucketModal.value = false;
|
||||
newBucketName.value = '';
|
||||
} catch (err) {
|
||||
alert('创建失败: ' + (err.response?.data?.error || err.message));
|
||||
}
|
||||
};
|
||||
|
||||
// Select Bucket
|
||||
const selectBucket = (name) => {
|
||||
currentBucket.value = name;
|
||||
nextToken.value = null;
|
||||
pageHistory.value = [];
|
||||
loadFiles();
|
||||
};
|
||||
|
||||
// Load Files
|
||||
const currentToken = ref(null);
|
||||
|
||||
const loadFilesWrapped = async (token) => {
|
||||
loadingFiles.value = true;
|
||||
try {
|
||||
const res = await api.get('/files/list', {
|
||||
params: {
|
||||
bucket_name: currentBucket.value,
|
||||
prefix: filters.value.prefix,
|
||||
max_keys: filters.value.maxKeys,
|
||||
token: token
|
||||
}
|
||||
});
|
||||
files.value = res.data.Files || [];
|
||||
currentToken.value = token;
|
||||
nextToken.value = res.data.NextContinuationToken;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
loadingFiles.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const loadFiles = () => loadFilesWrapped(null);
|
||||
|
||||
const refreshFiles = () => {
|
||||
nextToken.value = null;
|
||||
pageHistory.value = [];
|
||||
loadFiles();
|
||||
};
|
||||
|
||||
const nextP = () => {
|
||||
if(!nextToken.value) return;
|
||||
pageHistory.value.push(currentToken.value);
|
||||
loadFilesWrapped(nextToken.value);
|
||||
}
|
||||
|
||||
const prevPage = () => {
|
||||
if(pageHistory.value.length === 0) return;
|
||||
const prevToken = pageHistory.value.pop();
|
||||
loadFilesWrapped(prevToken);
|
||||
}
|
||||
|
||||
// File Upload
|
||||
const triggerFileInput = () => document.querySelector('input[type=file]').click();
|
||||
|
||||
const handleFileSelect = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
e.target.value = '';
|
||||
|
||||
const uploadTask = {
|
||||
id: Date.now(),
|
||||
file: file,
|
||||
progress: 0,
|
||||
status: 'pending',
|
||||
error: null,
|
||||
uploadId: null,
|
||||
parts: []
|
||||
};
|
||||
uploads.value.unshift(uploadTask);
|
||||
|
||||
processUpload(uploadTask);
|
||||
};
|
||||
|
||||
const processUpload = async (task) => {
|
||||
task.status = 'uploading';
|
||||
const file = task.file;
|
||||
const bucket = currentBucket.value;
|
||||
const key = file.name;
|
||||
|
||||
try {
|
||||
const initRes = await api.post('/files/multipart/init', {
|
||||
bucket_name: bucket,
|
||||
object_key: key
|
||||
});
|
||||
task.uploadId = initRes.data.upload_id;
|
||||
|
||||
const totalParts = Math.ceil(file.size / CHUNK_SIZE);
|
||||
const parts = [];
|
||||
|
||||
for (let i = 0; i < totalParts; i++) {
|
||||
const start = i * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
const partNumber = i + 1;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('bucket_name', bucket);
|
||||
formData.append('object_key', key);
|
||||
formData.append('upload_id', task.uploadId);
|
||||
formData.append('part_number', partNumber);
|
||||
formData.append('file', chunk);
|
||||
|
||||
let retries = 3;
|
||||
let etag = null;
|
||||
while(retries > 0) {
|
||||
try {
|
||||
const partRes = await api.put('/files/multipart/part', formData);
|
||||
etag = partRes.data.etag;
|
||||
break;
|
||||
} catch(e) {
|
||||
retries--;
|
||||
if(retries === 0) throw e;
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
parts.push({ PartNumber: partNumber, ETag: etag });
|
||||
task.progress = Math.round(((i + 1) / totalParts) * 100);
|
||||
}
|
||||
|
||||
await api.post('/files/multipart/complete', {
|
||||
bucket_name: bucket,
|
||||
object_key: key,
|
||||
upload_id: task.uploadId,
|
||||
parts: parts
|
||||
});
|
||||
|
||||
task.status = 'completed';
|
||||
task.progress = 100;
|
||||
setTimeout(() => refreshFiles(), 1000);
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
task.status = 'failed';
|
||||
task.error = err.response?.data?.error || err.message;
|
||||
}
|
||||
};
|
||||
|
||||
// Preview
|
||||
const previewFile = async (key) => {
|
||||
try {
|
||||
const ext = key.split('.').pop().toLowerCase();
|
||||
if (['md', 'markdown'].includes(ext)) {
|
||||
// Markdown 文件:通过后端接口获取文本内容,前端渲染
|
||||
previewUrl.value = 'loading';
|
||||
previewType.value = 'markdown';
|
||||
const res = await api.get('/files/content', {
|
||||
params: { bucket_name: currentBucket.value, object_key: key }
|
||||
});
|
||||
markdownHtml.value = marked.parse(res.data.content);
|
||||
} else {
|
||||
// 其他文件:获取 presigned URL 预览
|
||||
const res = await api.get('/files/preview', {
|
||||
params: { bucket_name: currentBucket.value, object_key: key }
|
||||
});
|
||||
previewUrl.value = res.data.url;
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) {
|
||||
previewType.value = 'image';
|
||||
} else if (['mp4', 'webm', 'ogg'].includes(ext)) {
|
||||
previewType.value = 'video';
|
||||
} else {
|
||||
previewType.value = 'other';
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
previewUrl.value = null;
|
||||
alert('无法获取预览内容');
|
||||
}
|
||||
};
|
||||
|
||||
const isPreviewImage = computed(() => previewType.value === 'image');
|
||||
const isPreviewVideo = computed(() => previewType.value === 'video');
|
||||
const isPreviewMarkdown = computed(() => previewType.value === 'markdown');
|
||||
|
||||
// Download
|
||||
const downloadFile = (key) => {
|
||||
const url = `${window.location.origin}/files/download?bucket_name=${encodeURIComponent(currentBucket.value)}&object_key=${encodeURIComponent(key)}`;
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
// Delete File
|
||||
const deleteFile = async (key) => {
|
||||
if (!confirm(`确定要删除文件 "${key}" 吗?此操作不可恢复!`)) return;
|
||||
try {
|
||||
await api.delete('/files/delete', {
|
||||
data: { bucket_name: currentBucket.value, object_key: key }
|
||||
});
|
||||
loadFilesWrapped(currentToken.value);
|
||||
} catch (err) {
|
||||
alert('删除失败: ' + (err.response?.data?.error || err.message));
|
||||
}
|
||||
};
|
||||
|
||||
// Delete Bucket
|
||||
const deleteBucket = async (bucketName) => {
|
||||
if (!confirm(`确定要删除存储桶 "${bucketName}" 吗?\n注意:存储桶必须为空才能删除!`)) return;
|
||||
try {
|
||||
await api.delete('/buckets', {
|
||||
data: { bucket_name: bucketName }
|
||||
});
|
||||
if (currentBucket.value === bucketName) {
|
||||
currentBucket.value = null;
|
||||
files.value = [];
|
||||
}
|
||||
await loadBuckets();
|
||||
} catch (err) {
|
||||
alert('删除存储桶失败: ' + (err.response?.data?.error || err.message));
|
||||
}
|
||||
};
|
||||
|
||||
// Utils
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
return new Date(dateStr).toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
const getFileIcon = (filename) => {
|
||||
const ext = filename.split('.').pop().toLowerCase();
|
||||
if (['jpg', 'png', 'gif'].includes(ext)) return 'fas fa-file-image text-primary';
|
||||
if (['pdf', 'doc', 'docx'].includes(ext)) return 'fas fa-file-pdf text-danger';
|
||||
if (['mp4', 'avi'].includes(ext)) return 'fas fa-file-video text-success';
|
||||
if (['zip', 'rar'].includes(ext)) return 'fas fa-file-archive text-warning';
|
||||
if (['md', 'markdown'].includes(ext)) return 'fas fa-file-code text-info';
|
||||
return 'fas fa-file text-secondary';
|
||||
};
|
||||
|
||||
const getProgressBarClass = (status) => {
|
||||
if(status === 'completed') return 'bg-success';
|
||||
if(status === 'failed') return 'bg-danger';
|
||||
return 'bg-primary';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkAuth();
|
||||
if (isAuthenticated.value) {
|
||||
loadBuckets();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isAuthenticated,
|
||||
buckets, currentBucket, files, loadingFiles, nextToken, pageHistory, filters,
|
||||
showCreateBucketModal, newBucketName, uploads, previewUrl, isPreviewImage, isPreviewVideo, isPreviewMarkdown, markdownHtml,
|
||||
logout,
|
||||
loadBuckets, createBucket, selectBucket, deleteBucket, refreshFiles,
|
||||
nextPage: nextP, prevPage,
|
||||
triggerFileInput, handleFileSelect,
|
||||
previewFile, downloadFile, deleteFile,
|
||||
formatSize, formatDate, getFileIcon, getProgressBarClass
|
||||
};
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
212
web/login.html
212
web/login.html
@ -1,212 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RustFS - 用户登录</title>
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Font Awesome -->
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
}
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.login-header i {
|
||||
font-size: 3rem;
|
||||
color: #667eea;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.login-header h2 {
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.login-header p {
|
||||
color: #666;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.form-control {
|
||||
border-radius: 10px;
|
||||
padding: 12px 15px;
|
||||
border: 2px solid #e0e0e0;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.form-control:focus {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
|
||||
}
|
||||
.input-group-text {
|
||||
background: white;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-right: none;
|
||||
border-radius: 10px 0 0 10px;
|
||||
}
|
||||
.input-group .form-control {
|
||||
border-left: none;
|
||||
border-radius: 0 10px 10px 0;
|
||||
}
|
||||
.input-group .form-control:focus {
|
||||
border-color: #667eea;
|
||||
}
|
||||
.btn-login {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
width: 100%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.btn-login:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
|
||||
color: white;
|
||||
}
|
||||
.btn-login:disabled {
|
||||
background: #ccc;
|
||||
transform: none;
|
||||
}
|
||||
.alert {
|
||||
border-radius: 10px;
|
||||
display: none;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 25px;
|
||||
color: #999;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
[v-cloak] { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="container" v-cloak>
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<i class="fas fa-cloud"></i>
|
||||
<h2>RustFS 文件管理</h2>
|
||||
<p>请输入 API 密钥以访问系统</p>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-danger" role="alert" :style="{ display: showError ? 'block' : 'none' }">
|
||||
<i class="fas fa-exclamation-circle me-2"></i>{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="login">
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-bold">API 密钥</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">
|
||||
<i class="fas fa-key"></i>
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
placeholder="请输入 API 密钥"
|
||||
v-model="apiKey"
|
||||
:disabled="loading"
|
||||
required
|
||||
autofocus
|
||||
>
|
||||
</div>
|
||||
<div class="form-text text-muted mt-2">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
请输入管理员分配的 API 密钥
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-login" :disabled="loading">
|
||||
<span v-if="loading">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
登录中...
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="fas fa-sign-in-alt me-2"></i>登 录
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="footer">
|
||||
<p class="mb-1">Powered by RustFS & Gin</p>
|
||||
<a href="/swagger/index.html" target="_blank" class="text-decoration-none">
|
||||
<i class="fas fa-book me-1"></i>API 文档
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/vue@3.3.4/dist/vue.global.prod.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script>
|
||||
const { createApp, ref } = Vue;
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const apiKey = ref('');
|
||||
const loading = ref(false);
|
||||
const showError = ref(false);
|
||||
const errorMessage = ref('');
|
||||
|
||||
const login = async () => {
|
||||
if (!apiKey.value.trim()) {
|
||||
showError.value = true;
|
||||
errorMessage.value = '请输入 API 密钥';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
showError.value = false;
|
||||
|
||||
try {
|
||||
const res = await axios.post('/auth/login', {
|
||||
api_key: apiKey.value
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
// 保存 token 到 localStorage
|
||||
localStorage.setItem('rustfs_token', res.data.token);
|
||||
// 跳转到主页面
|
||||
window.location.href = '/web/';
|
||||
} else {
|
||||
showError.value = true;
|
||||
errorMessage.value = res.data.message || '登录失败';
|
||||
}
|
||||
} catch (err) {
|
||||
showError.value = true;
|
||||
errorMessage.value = err.response?.data?.error || err.message || '登录请求失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
loading,
|
||||
showError,
|
||||
errorMessage,
|
||||
login
|
||||
};
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user