implemented mongo gridFS for file upload
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"tm/pkg/authorization"
|
"tm/pkg/authorization"
|
||||||
"tm/pkg/config"
|
"tm/pkg/config"
|
||||||
|
"tm/pkg/filestore"
|
||||||
"tm/pkg/glm"
|
"tm/pkg/glm"
|
||||||
"tm/pkg/hcaptcha"
|
"tm/pkg/hcaptcha"
|
||||||
"tm/pkg/logger"
|
"tm/pkg/logger"
|
||||||
@@ -315,3 +316,20 @@ func InitGLMService(conf GLMConfig, log logger.Logger) *glm.SDK {
|
|||||||
|
|
||||||
return glmSDK
|
return glmSDK
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitFileStore initializes the GridFS file store service
|
||||||
|
func InitFileStore(mongoManager *mongo.ConnectionManager, log logger.Logger) (*filestore.GridFSService, error) {
|
||||||
|
fileStoreService, err := filestore.NewGridFSService(mongoManager, log)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to initialize file store service", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("File store service initialized successfully", map[string]interface{}{
|
||||||
|
"storage_type": "MongoDB GridFS",
|
||||||
|
})
|
||||||
|
|
||||||
|
return fileStoreService, nil
|
||||||
|
}
|
||||||
|
|||||||
+13
-3
@@ -109,6 +109,7 @@ import (
|
|||||||
"tm/internal/tender"
|
"tm/internal/tender"
|
||||||
"tm/internal/tender_approval"
|
"tm/internal/tender_approval"
|
||||||
"tm/internal/user"
|
"tm/internal/user"
|
||||||
|
"tm/pkg/filestore"
|
||||||
"tm/pkg/security"
|
"tm/pkg/security"
|
||||||
|
|
||||||
"tm/cmd/web/bootstrap"
|
"tm/cmd/web/bootstrap"
|
||||||
@@ -198,6 +199,14 @@ func main() {
|
|||||||
notificationSDK := bootstrap.InitNotificationService(conf.Notification, logger)
|
notificationSDK := bootstrap.InitNotificationService(conf.Notification, logger)
|
||||||
xssPolicy := security.NewXSSPolicy()
|
xssPolicy := security.NewXSSPolicy()
|
||||||
|
|
||||||
|
// Initialize file store service
|
||||||
|
fileStoreService, err := bootstrap.InitFileStore(mongoManager, logger)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal("Failed to initialize file store service", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize hCaptcha verifier
|
// Initialize hCaptcha verifier
|
||||||
hcaptchaVerifier := bootstrap.InitHCaptchaService(conf.HCaptcha, logger)
|
hcaptchaVerifier := bootstrap.InitHCaptchaService(conf.HCaptcha, logger)
|
||||||
|
|
||||||
@@ -267,16 +276,17 @@ func main() {
|
|||||||
cmsHandler := cms.NewHandler(cmsService)
|
cmsHandler := cms.NewHandler(cmsService)
|
||||||
scraperHandler := scraper.NewHandler(scraperService)
|
scraperHandler := scraper.NewHandler(scraperService)
|
||||||
kanbanHandler := kanban.NewHandler(kanbanService)
|
kanbanHandler := kanban.NewHandler(kanbanService)
|
||||||
|
fileStoreHandler := filestore.NewHandler(fileStoreService, logger)
|
||||||
logger.Info("Handlers initialized successfully", map[string]interface{}{
|
logger.Info("Handlers initialized successfully", map[string]interface{}{
|
||||||
"handlers": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban"},
|
"handlers": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper", "kanban", "filestore"},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initialize HTTP server
|
// Initialize HTTP server
|
||||||
e := bootstrap.InitHTTPServer(conf, logger)
|
e := bootstrap.InitHTTPServer(conf, logger)
|
||||||
|
|
||||||
// Register routes
|
// Register routes
|
||||||
router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, scraperHandler, kanbanHandler, xssPolicy)
|
router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, scraperHandler, kanbanHandler, fileStoreHandler, xssPolicy)
|
||||||
router.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, xssPolicy)
|
router.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, fileStoreHandler, xssPolicy)
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
serverAddr := fmt.Sprintf("%s:%d", conf.Server.Host, conf.Server.Port)
|
serverAddr := fmt.Sprintf("%s:%d", conf.Server.Host, conf.Server.Port)
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ import (
|
|||||||
"tm/internal/tender"
|
"tm/internal/tender"
|
||||||
"tm/internal/tender_approval"
|
"tm/internal/tender_approval"
|
||||||
"tm/internal/user"
|
"tm/internal/user"
|
||||||
|
"tm/pkg/filestore"
|
||||||
"tm/pkg/security"
|
"tm/pkg/security"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler *company.Handler, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, scraperHandler *scraper.Handler, kanbanHandler *kanban.Handler, xssPolicy *security.XSSPolicy) {
|
func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler *company.Handler, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, scraperHandler *scraper.Handler, kanbanHandler *kanban.Handler, fileStoreHandler *filestore.Handler, xssPolicy *security.XSSPolicy) {
|
||||||
adminV1 := e.Group("/admin/v1")
|
adminV1 := e.Group("/admin/v1")
|
||||||
|
|
||||||
// Add CSP middleware to admin routes (stricter policy)
|
// Add CSP middleware to admin routes (stricter policy)
|
||||||
@@ -192,9 +193,20 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler
|
|||||||
kanbanGP.Use(userHandler.AuthMiddleware())
|
kanbanGP.Use(userHandler.AuthMiddleware())
|
||||||
kanbanHandler.RegisterRoutes(kanbanGP)
|
kanbanHandler.RegisterRoutes(kanbanGP)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// File Store Routes
|
||||||
|
filesGP := adminV1.Group("/files")
|
||||||
|
{
|
||||||
|
filesGP.Use(userHandler.AuthMiddleware())
|
||||||
|
filesGP.POST("/upload", fileStoreHandler.Upload)
|
||||||
|
filesGP.GET("", fileStoreHandler.ListFiles)
|
||||||
|
filesGP.GET("/:file_id/info", fileStoreHandler.GetInfo)
|
||||||
|
filesGP.GET("/:file_id/download", fileStoreHandler.Download)
|
||||||
|
filesGP.DELETE("/:file_id", fileStoreHandler.Delete)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, companyHandler *company.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, xssPolicy *security.XSSPolicy) {
|
func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, companyHandler *company.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, fileStoreHandler *filestore.Handler, xssPolicy *security.XSSPolicy) {
|
||||||
v1 := e.Group("/api/v1")
|
v1 := e.Group("/api/v1")
|
||||||
|
|
||||||
// Add CSP middleware to public routes
|
// Add CSP middleware to public routes
|
||||||
@@ -292,4 +304,15 @@ func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tende
|
|||||||
{
|
{
|
||||||
cmsGP.GET("/:key", cmsHandler.GetByKey)
|
cmsGP.GET("/:key", cmsHandler.GetByKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// File Store Routes
|
||||||
|
publicFilesGP := v1.Group("/files")
|
||||||
|
{
|
||||||
|
publicFilesGP.Use(customerHandler.AuthMiddleware())
|
||||||
|
publicFilesGP.POST("/upload", fileStoreHandler.Upload)
|
||||||
|
publicFilesGP.GET("", fileStoreHandler.ListFiles)
|
||||||
|
publicFilesGP.GET("/:file_id/info", fileStoreHandler.GetInfo)
|
||||||
|
publicFilesGP.GET("/:file_id/download", fileStoreHandler.Download)
|
||||||
|
publicFilesGP.DELETE("/:file_id", fileStoreHandler.Delete)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ require (
|
|||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
github.com/swaggo/echo-swagger v1.4.1
|
github.com/swaggo/echo-swagger v1.4.1
|
||||||
github.com/swaggo/swag v1.16.6
|
github.com/swaggo/swag v1.16.6
|
||||||
go.mongodb.org/mongo-driver/v2 v2.3.0
|
go.mongodb.org/mongo-driver v1.17.9
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.1
|
||||||
go.uber.org/zap v1.26.0
|
go.uber.org/zap v1.26.0
|
||||||
golang.org/x/crypto v0.40.0
|
golang.org/x/crypto v0.40.0
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
@@ -41,6 +42,7 @@ require (
|
|||||||
github.com/mailru/easyjson v0.9.0 // indirect
|
github.com/mailru/easyjson v0.9.0 // indirect
|
||||||
github.com/minio/crc64nvme v1.1.0 // indirect
|
github.com/minio/crc64nvme v1.1.0 // indirect
|
||||||
github.com/minio/md5-simd v1.1.2 // indirect
|
github.com/minio/md5-simd v1.1.2 // indirect
|
||||||
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
github.com/philhofer/fwd v1.2.0 // indirect
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/rs/xid v1.6.0 // indirect
|
github.com/rs/xid v1.6.0 // indirect
|
||||||
@@ -64,7 +66,7 @@ require (
|
|||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||||
github.com/xdg-go/scram v1.1.2 // indirect
|
github.com/xdg-go/scram v1.2.0 // indirect
|
||||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
go.uber.org/multierr v1.10.0 // indirect
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
|||||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||||
github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
|
github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
|
||||||
github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk=
|
github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk=
|
||||||
|
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||||
|
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
@@ -101,15 +103,17 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
|
|||||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.3.0 h1:sh55yOXA2vUjW1QYw/2tRlHSQViwDyPnW61AwpZ4rtU=
|
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.3.0/go.mod h1:jHeEDJHJq7tm6ZF45Issun9dbogjfnPySb1vXA7EeAI=
|
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.1 h1:j2U/Qp+wvueSpqitLCSZPT/+ZpVc1xzuwdHWwl7d8ro=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.1/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
|
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
|
||||||
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
|
||||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ type Company struct {
|
|||||||
Phone *string `bson:"phone,omitempty" json:"phone,omitempty"`
|
Phone *string `bson:"phone,omitempty" json:"phone,omitempty"`
|
||||||
Email *string `bson:"email,omitempty" json:"email,omitempty"`
|
Email *string `bson:"email,omitempty" json:"email,omitempty"`
|
||||||
|
|
||||||
|
// File references stored in GridFS
|
||||||
|
DocumentFileIDs []string `bson:"document_file_ids,omitempty" json:"document_file_ids,omitempty"`
|
||||||
|
|
||||||
// Tags for categorization and search
|
// Tags for categorization and search
|
||||||
Tags *CompanyTags `bson:"tags,omitempty" json:"tags,omitempty"`
|
Tags *CompanyTags `bson:"tags,omitempty" json:"tags,omitempty"`
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ type (
|
|||||||
// Contact information
|
// Contact information
|
||||||
Phone *string `json:"phone,omitempty" valid:"optional,length(10|20)" example:"+1234567890"`
|
Phone *string `json:"phone,omitempty" valid:"optional,length(10|20)" example:"+1234567890"`
|
||||||
Email *string `json:"email,omitempty" valid:"optional,email" example:"info@opplens.com"`
|
Email *string `json:"email,omitempty" valid:"optional,email" example:"info@opplens.com"`
|
||||||
|
// GridFS document references (uploaded via /api/v1/files/upload or /admin/v1/files/upload)
|
||||||
|
DocumentFileIDs []string `json:"document_file_ids,omitempty" valid:"optional"`
|
||||||
|
|
||||||
// Tags for categorization and search
|
// Tags for categorization and search
|
||||||
Tags *CompanyTagsForm `json:"tags,omitempty"`
|
Tags *CompanyTagsForm `json:"tags,omitempty"`
|
||||||
@@ -122,6 +124,7 @@ type (
|
|||||||
Address *Address `json:"address,omitempty"`
|
Address *Address `json:"address,omitempty"`
|
||||||
Phone *string `json:"phone,omitempty"`
|
Phone *string `json:"phone,omitempty"`
|
||||||
Email *string `json:"email,omitempty"`
|
Email *string `json:"email,omitempty"`
|
||||||
|
DocumentFileIDs []string `json:"document_file_ids,omitempty"`
|
||||||
Tags *CompanyTagsResponse `json:"tags,omitempty"`
|
Tags *CompanyTagsResponse `json:"tags,omitempty"`
|
||||||
IsVerified bool `json:"is_verified"`
|
IsVerified bool `json:"is_verified"`
|
||||||
IsCompliant bool `json:"is_compliant"`
|
IsCompliant bool `json:"is_compliant"`
|
||||||
@@ -160,6 +163,7 @@ func (c *Company) ToResponse(categories []*CategoryResponse) *CompanyResponse {
|
|||||||
Address: c.Address,
|
Address: c.Address,
|
||||||
Phone: c.Phone,
|
Phone: c.Phone,
|
||||||
Email: c.Email,
|
Email: c.Email,
|
||||||
|
DocumentFileIDs: c.DocumentFileIDs,
|
||||||
Tags: c.Tags.ToResponse(categories),
|
Tags: c.Tags.ToResponse(categories),
|
||||||
IsVerified: c.IsVerified,
|
IsVerified: c.IsVerified,
|
||||||
IsCompliant: c.IsCompliant,
|
IsCompliant: c.IsCompliant,
|
||||||
@@ -189,6 +193,7 @@ type CompanyProfileResponse struct {
|
|||||||
RegistrationNumber string `json:"registration_number"`
|
RegistrationNumber string `json:"registration_number"`
|
||||||
Industry string `json:"industry"`
|
Industry string `json:"industry"`
|
||||||
FoundedYear *int `json:"founded_year,omitempty"`
|
FoundedYear *int `json:"founded_year,omitempty"`
|
||||||
|
DocumentFileIDs []string `json:"document_file_ids,omitempty"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,6 +205,7 @@ func (c *Company) ToProfileResponse() *CompanyProfileResponse {
|
|||||||
RegistrationNumber: c.RegistrationNumber,
|
RegistrationNumber: c.RegistrationNumber,
|
||||||
Industry: c.Industry,
|
Industry: c.Industry,
|
||||||
FoundedYear: c.FoundedYear,
|
FoundedYear: c.FoundedYear,
|
||||||
|
DocumentFileIDs: c.DocumentFileIDs,
|
||||||
CreatedAt: c.CreatedAt,
|
CreatedAt: c.CreatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ func (s *companyService) Create(ctx context.Context, form *CompanyForm) (*Compan
|
|||||||
Address: s.convertAddressForm(form.Address),
|
Address: s.convertAddressForm(form.Address),
|
||||||
Phone: form.Phone,
|
Phone: form.Phone,
|
||||||
Email: form.Email,
|
Email: form.Email,
|
||||||
|
DocumentFileIDs: form.DocumentFileIDs,
|
||||||
Tags: s.convertCompanyTagsForm(form.Tags),
|
Tags: s.convertCompanyTagsForm(form.Tags),
|
||||||
IsVerified: false,
|
IsVerified: false,
|
||||||
IsCompliant: false,
|
IsCompliant: false,
|
||||||
@@ -259,6 +260,10 @@ func (s *companyService) Update(ctx context.Context, id string, form *CompanyFor
|
|||||||
company.Email = form.Email
|
company.Email = form.Email
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if form.DocumentFileIDs != nil {
|
||||||
|
company.DocumentFileIDs = form.DocumentFileIDs
|
||||||
|
}
|
||||||
|
|
||||||
if form.Tags != nil {
|
if form.Tags != nil {
|
||||||
company.Tags = s.convertCompanyTagsForm(form.Tags)
|
company.Tags = s.convertCompanyTagsForm(form.Tags)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"tm/pkg/response"
|
"tm/pkg/response"
|
||||||
|
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
mongopkg "go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Repository defines methods for user data access
|
// Repository defines methods for user data access
|
||||||
@@ -26,8 +27,9 @@ type Repository interface {
|
|||||||
|
|
||||||
// userRepository implements the Repository interface using the MongoDB ORM
|
// userRepository implements the Repository interface using the MongoDB ORM
|
||||||
type userRepository struct {
|
type userRepository struct {
|
||||||
ormRepo orm.Repository[User]
|
ormRepo orm.Repository[User]
|
||||||
logger logger.Logger
|
collection *mongopkg.Collection
|
||||||
|
logger logger.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUserRepository creates a new user repository
|
// NewUserRepository creates a new user repository
|
||||||
@@ -46,11 +48,13 @@ func NewRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create ORM repository
|
// Create ORM repository
|
||||||
ormRepo := orm.NewRepository[User](mongoManager.GetCollection("users"), logger)
|
collection := mongoManager.GetCollection("users")
|
||||||
|
ormRepo := orm.NewRepository[User](collection, logger)
|
||||||
|
|
||||||
return &userRepository{
|
return &userRepository{
|
||||||
ormRepo: ormRepo,
|
ormRepo: ormRepo,
|
||||||
logger: logger,
|
collection: collection,
|
||||||
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +121,35 @@ func (r *userRepository) Update(ctx context.Context, user *User) error {
|
|||||||
// Use ORM to update user
|
// Use ORM to update user
|
||||||
err := r.ormRepo.Update(ctx, user)
|
err := r.ormRepo.Update(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Backward compatibility for legacy records with string _id.
|
||||||
|
legacyFilterID := user.ID.Hex()
|
||||||
|
updateDoc := bson.M{
|
||||||
|
"$set": bson.M{
|
||||||
|
"full_name": user.FullName,
|
||||||
|
"username": user.Username,
|
||||||
|
"email": user.Email,
|
||||||
|
"password": user.Password,
|
||||||
|
"role": user.Role,
|
||||||
|
"status": user.Status,
|
||||||
|
"department": user.Department,
|
||||||
|
"position": user.Position,
|
||||||
|
"phone": user.Phone,
|
||||||
|
"profile_image": user.ProfileImage,
|
||||||
|
"device_token": user.DeviceToken,
|
||||||
|
"is_verified": user.IsVerified,
|
||||||
|
"last_login_at": user.LastLoginAt,
|
||||||
|
"updated_at": user.UpdatedAt,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
legacyResult, legacyErr := r.collection.UpdateOne(ctx, bson.M{"_id": legacyFilterID}, updateDoc)
|
||||||
|
if legacyErr == nil && legacyResult.MatchedCount > 0 {
|
||||||
|
r.logger.Info("User updated successfully using legacy string ID", map[string]interface{}{
|
||||||
|
"user_id": legacyFilterID,
|
||||||
|
"email": user.Email,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
r.logger.Error("Failed to update user", map[string]interface{}{
|
r.logger.Error("Failed to update user", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
"user_id": user.ID,
|
"user_id": user.ID,
|
||||||
@@ -137,7 +170,19 @@ func (r *userRepository) GetByID(ctx context.Context, id string) (*User, error)
|
|||||||
user, err := r.ormRepo.FindByID(ctx, id)
|
user, err := r.ormRepo.FindByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, orm.ErrDocumentNotFound) {
|
if errors.Is(err, orm.ErrDocumentNotFound) {
|
||||||
return nil, errors.New("user not found")
|
// Backward compatibility: some legacy seed data may store _id as string instead of ObjectID.
|
||||||
|
legacyUser, legacyErr := r.ormRepo.FindOne(ctx, bson.M{"_id": id})
|
||||||
|
if legacyErr == nil {
|
||||||
|
return legacyUser, nil
|
||||||
|
}
|
||||||
|
if errors.Is(legacyErr, orm.ErrDocumentNotFound) {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
r.logger.Error("Failed to get user by legacy string ID", map[string]interface{}{
|
||||||
|
"error": legacyErr.Error(),
|
||||||
|
"user_id": id,
|
||||||
|
})
|
||||||
|
return nil, legacyErr
|
||||||
}
|
}
|
||||||
r.logger.Error("Failed to get user by ID", map[string]interface{}{
|
r.logger.Error("Failed to get user by ID", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|||||||
@@ -0,0 +1,510 @@
|
|||||||
|
# MongoDB GridFS File Store Service
|
||||||
|
|
||||||
|
A comprehensive file storage service for the Opplens backend using MongoDB GridFS for scalable, distributed file storage.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The file store service provides a complete solution for managing file uploads, downloads, and deletions using MongoDB's GridFS. It supports:
|
||||||
|
|
||||||
|
- **Secure file uploads** with validation and size limits
|
||||||
|
- **Metadata storage** for tracking file information
|
||||||
|
- **File categorization** and tagging
|
||||||
|
- **List and search** functionality
|
||||||
|
- **Stream-based downloads** for efficient memory usage
|
||||||
|
- **Access control** and user tracking
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
✅ **MongoDB GridFS Backend** - Native MongoDB file storage without external dependencies
|
||||||
|
✅ **File Validation** - Filename, MIME type, and size validation
|
||||||
|
✅ **Metadata Support** - Store rich metadata with files
|
||||||
|
✅ **Category & Tags** - Organize files with categories and tags
|
||||||
|
✅ **Stream Processing** - Efficient memory usage for large files
|
||||||
|
✅ **Error Handling** - Comprehensive error codes and messages
|
||||||
|
✅ **Logging** - Detailed operation logging
|
||||||
|
✅ **Thread-Safe** - Concurrent operations support
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ HTTP Handler (Echo) │
|
||||||
|
│ - Upload, Download, Delete, List │
|
||||||
|
└────────────┬────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────▼────────────────────────┐
|
||||||
|
│ File Handler Service │
|
||||||
|
│ - Request validation │
|
||||||
|
│ - Response formatting │
|
||||||
|
└────────────┬────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────▼────────────────────────┐
|
||||||
|
│ GridFS Service Interface │
|
||||||
|
│ - Upload, Download, Delete, List │
|
||||||
|
└────────────┬────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────▼────────────────────────┐
|
||||||
|
│ MongoDB GridFS Implementation │
|
||||||
|
│ - Bucket operations │
|
||||||
|
│ - Metadata management │
|
||||||
|
└────────────┬────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────▼────────────────────────┐
|
||||||
|
│ MongoDB Database │
|
||||||
|
│ - fs.files collection │
|
||||||
|
│ - fs.chunks collection │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
The service is automatically initialized in the bootstrap:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// In cmd/web/bootstrap/bootstrap.go
|
||||||
|
fileStoreService, err := filestore.NewGridFSService(mongoManager, logger)
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal("Failed to initialize file store service", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Upload File
|
||||||
|
|
||||||
|
**POST** `/api/v1/files/upload`
|
||||||
|
|
||||||
|
Upload a file to GridFS storage.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
- Content-Type: `multipart/form-data`
|
||||||
|
- Parameters:
|
||||||
|
- `file` (required): The file to upload
|
||||||
|
- `category` (optional): File category for organization
|
||||||
|
- `tags` (optional, array): Tags for the file
|
||||||
|
- `description` (optional): File description
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file_id": "507f1f77bcf86cd799439011",
|
||||||
|
"filename": "profile.jpg",
|
||||||
|
"content_type": "image/jpeg",
|
||||||
|
"size": 102400,
|
||||||
|
"message": "File uploaded successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- `400 Bad Request` - No file provided
|
||||||
|
- `413 Payload Too Large` - File exceeds size limit
|
||||||
|
- `500 Internal Server Error` - Upload failed
|
||||||
|
|
||||||
|
### Download File
|
||||||
|
|
||||||
|
**GET** `/api/v1/files/{file_id}/download`
|
||||||
|
|
||||||
|
Download a file by its ID.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `file_id` (path, required): The file ID
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
- Returns file content with appropriate MIME type and `Content-Disposition` header
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- `404 Not Found` - File not found
|
||||||
|
- `500 Internal Server Error` - Download failed
|
||||||
|
|
||||||
|
### Get File Info
|
||||||
|
|
||||||
|
**GET** `/api/v1/files/{file_id}/info`
|
||||||
|
|
||||||
|
Retrieve metadata for a file.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `file_id` (path, required): The file ID
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file_id": "507f1f77bcf86cd799439011",
|
||||||
|
"filename": "profile.jpg",
|
||||||
|
"content_type": "image/jpeg",
|
||||||
|
"size": 102400,
|
||||||
|
"uploaded_by": "user_123",
|
||||||
|
"uploaded_at": "2024-04-19T10:30:00Z",
|
||||||
|
"category": "profile_images",
|
||||||
|
"tags": ["user", "profile"],
|
||||||
|
"description": "User profile picture"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Files
|
||||||
|
|
||||||
|
**GET** `/api/v1/files`
|
||||||
|
|
||||||
|
List files with optional filtering.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `category` (optional): Filter by category
|
||||||
|
- `tags` (optional, array): Filter by tags
|
||||||
|
- `limit` (optional, default 50, max 1000): Number of files to return
|
||||||
|
- `skip` (optional, default 0): Number of files to skip
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"file_id": "507f1f77bcf86cd799439011",
|
||||||
|
"filename": "profile.jpg",
|
||||||
|
"content_type": "image/jpeg",
|
||||||
|
"size": 102400,
|
||||||
|
"uploaded_by": "user_123",
|
||||||
|
"uploaded_at": "2024-04-19T10:30:00Z",
|
||||||
|
"category": "profile_images",
|
||||||
|
"tags": ["user", "profile"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 150,
|
||||||
|
"limit": 50,
|
||||||
|
"skip": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete File
|
||||||
|
|
||||||
|
**DELETE** `/api/v1/files/{file_id}`
|
||||||
|
|
||||||
|
Delete a file by its ID.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `file_id` (path, required): The file ID
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "File deleted successfully"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- `404 Not Found` - File not found
|
||||||
|
- `500 Internal Server Error` - Deletion failed
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Upload Profile Image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
-F "file=@profile.jpg" \
|
||||||
|
-F "category=profile_images" \
|
||||||
|
-F "tags=user" \
|
||||||
|
-F "tags=profile" \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
http://localhost:8080/api/v1/files/upload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Download File
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
http://localhost:8080/api/v1/files/507f1f77bcf86cd799439011/download \
|
||||||
|
-o downloaded_file.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get File Information
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
http://localhost:8080/api/v1/files/507f1f77bcf86cd799439011/info
|
||||||
|
```
|
||||||
|
|
||||||
|
### List User Profile Images
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
"http://localhost:8080/api/v1/files?category=profile_images&tags=user&limit=20"
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Validation
|
||||||
|
|
||||||
|
The service includes built-in validation for file uploads:
|
||||||
|
|
||||||
|
### Default Validators
|
||||||
|
|
||||||
|
**Standard File Validator**
|
||||||
|
- Supported MIME types: Images, PDFs, Office documents, compressed files
|
||||||
|
- Maximum size: 100 MB
|
||||||
|
- Filename validation and sanitization
|
||||||
|
|
||||||
|
**Profile Image Validator**
|
||||||
|
- Supported MIME types: JPEG, PNG, GIF, WebP
|
||||||
|
- Maximum size: 5 MB
|
||||||
|
- Extension validation
|
||||||
|
|
||||||
|
### Custom Validation
|
||||||
|
|
||||||
|
Create a custom validator:
|
||||||
|
|
||||||
|
```go
|
||||||
|
validator := filestore.NewFileValidator(
|
||||||
|
10 * 1024 * 1024, // 10 MB max
|
||||||
|
map[string]bool{
|
||||||
|
"application/pdf": true,
|
||||||
|
"image/jpeg": true,
|
||||||
|
"image/png": true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validate file
|
||||||
|
if err := validator.ValidateFile("document.pdf", "application/pdf", fileSize); err != nil {
|
||||||
|
// Handle validation error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Metadata Storage
|
||||||
|
|
||||||
|
Files are stored with rich metadata:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filename": "profile.jpg",
|
||||||
|
"uploadDate": "2024-04-19T10:30:00Z",
|
||||||
|
"metadata": {
|
||||||
|
"uploaded_by": "user_123",
|
||||||
|
"category": "profile_images",
|
||||||
|
"tags": ["user", "profile"],
|
||||||
|
"description": "User profile picture",
|
||||||
|
"content_type": "image/jpeg",
|
||||||
|
"extra": {
|
||||||
|
"user_id": "user_123",
|
||||||
|
"department": "marketing"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
The service uses a comprehensive error handling system:
|
||||||
|
|
||||||
|
### Error Codes
|
||||||
|
|
||||||
|
- `FILE_NOT_FOUND` - File does not exist
|
||||||
|
- `UPLOAD_FAILED` - Upload operation failed
|
||||||
|
- `DOWNLOAD_FAILED` - Download operation failed
|
||||||
|
- `DELETE_FAILED` - Delete operation failed
|
||||||
|
- `INVALID_INPUT` - Invalid input parameters
|
||||||
|
- `FILE_TOO_LARGE` - File exceeds size limit
|
||||||
|
- `UNSUPPORTED_FILE_TYPE` - MIME type not supported
|
||||||
|
- `DUPLICATE_FILE` - File already exists
|
||||||
|
- `ACCESS_DENIED` - User does not have access
|
||||||
|
|
||||||
|
### Error Response Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "File is too large",
|
||||||
|
"code": "FILE_TOO_LARGE",
|
||||||
|
"message": "Maximum file size is 100 MB"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### fs.files Collection
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
_id: ObjectId,
|
||||||
|
filename: String,
|
||||||
|
uploadDate: Date,
|
||||||
|
chunkSize: Number,
|
||||||
|
length: Number,
|
||||||
|
md5: String,
|
||||||
|
metadata: {
|
||||||
|
uploaded_by: String,
|
||||||
|
category: String,
|
||||||
|
tags: [String],
|
||||||
|
description: String,
|
||||||
|
content_type: String,
|
||||||
|
extra: {
|
||||||
|
user_id: String,
|
||||||
|
// ... additional fields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### fs.chunks Collection
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
_id: ObjectId,
|
||||||
|
files_id: ObjectId,
|
||||||
|
n: Number,
|
||||||
|
data: BinData
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration with User Service
|
||||||
|
|
||||||
|
To integrate file uploads with user profiles:
|
||||||
|
|
||||||
|
1. **Update User Entity** - Add FileID field:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type User struct {
|
||||||
|
// ... existing fields
|
||||||
|
ProfileImageFileID *string `bson:"profile_image_file_id"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add Upload Endpoint** - In user handler:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (h *Handler) UploadProfileImage(c echo.Context) error {
|
||||||
|
// Get user ID from context
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
|
||||||
|
// Get file
|
||||||
|
file, err := c.FormFile("profile_image")
|
||||||
|
// ... upload logic
|
||||||
|
|
||||||
|
// Update user with file ID
|
||||||
|
// ... update user
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Add Download Endpoint** - Serve user's profile image:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (h *Handler) GetProfileImage(c echo.Context) error {
|
||||||
|
userID := c.Param("user_id")
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
user, err := h.service.GetUserByID(userID)
|
||||||
|
|
||||||
|
// Download file
|
||||||
|
reader, fileInfo, err := h.fileStoreService.Download(*user.ProfileImageFileID)
|
||||||
|
// ... stream response
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
1. **Chunk Size** - GridFS default is 255 KB. For large files, consider increasing this.
|
||||||
|
2. **Indexing** - Create indexes on frequently queried metadata:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
db.fs.files.createIndex({ "metadata.category": 1 })
|
||||||
|
db.fs.files.createIndex({ "metadata.tags": 1 })
|
||||||
|
db.fs.files.createIndex({ "metadata.uploaded_by": 1 })
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Cleanup** - Implement regular cleanup for unused files:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Delete file and associated chunks
|
||||||
|
func (s *GridFSService) Delete(fileID string) error {
|
||||||
|
// Automatically handled by MongoDB
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **File Type Validation** - Validate MIME types and extensions
|
||||||
|
2. **Filename Sanitization** - Remove dangerous characters:
|
||||||
|
|
||||||
|
```go
|
||||||
|
sanitized := filestore.SanitizeFilename(filename)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Size Limits** - Enforce maximum file sizes
|
||||||
|
4. **Access Control** - Implement proper authorization checks
|
||||||
|
5. **Malware Scanning** - Consider integrating antivirus scanning
|
||||||
|
6. **Rate Limiting** - Implement rate limiting on upload endpoints
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Unit tests are included for all operations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./pkg/filestore -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Test coverage includes:
|
||||||
|
- Upload operations
|
||||||
|
- Download operations
|
||||||
|
- Delete operations
|
||||||
|
- File validation
|
||||||
|
- Error handling
|
||||||
|
- Metadata storage and retrieval
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### MongoDB Connection Failed
|
||||||
|
```
|
||||||
|
Error: MongoDB connection not available
|
||||||
|
Solution: Ensure MongoDB is running and configured correctly
|
||||||
|
```
|
||||||
|
|
||||||
|
### File Not Found
|
||||||
|
```
|
||||||
|
Error: file not found
|
||||||
|
Solution: Verify the file ID is correct
|
||||||
|
```
|
||||||
|
|
||||||
|
### File Too Large
|
||||||
|
```
|
||||||
|
Error: file size exceeds maximum limit
|
||||||
|
Solution: Check MaxSize configuration and file size
|
||||||
|
```
|
||||||
|
|
||||||
|
### Invalid MIME Type
|
||||||
|
```
|
||||||
|
Error: unsupported file type
|
||||||
|
Solution: Verify the file type is in the allowed MIME types list
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configure file store limits in `bootstrap.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Maximum file sizes
|
||||||
|
const (
|
||||||
|
MaxUploadSize = 100 * 1024 * 1024 // 100 MB
|
||||||
|
MaxProfileImageSize = 5 * 1024 * 1024 // 5 MB
|
||||||
|
)
|
||||||
|
|
||||||
|
// Supported MIME types
|
||||||
|
var AllowedImageTypes = map[string]bool{
|
||||||
|
"image/jpeg": true,
|
||||||
|
"image/png": true,
|
||||||
|
"image/gif": true,
|
||||||
|
"image/webp": true,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
When extending the file store service:
|
||||||
|
|
||||||
|
1. Maintain backward compatibility
|
||||||
|
2. Add appropriate error handling
|
||||||
|
3. Update logging for operations
|
||||||
|
4. Include unit tests for new features
|
||||||
|
5. Update documentation
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This file store service is part of the Opplens backend system.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package filestore
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// Error codes for file store operations
|
||||||
|
const (
|
||||||
|
ErrCodeNotFound = "FILE_NOT_FOUND"
|
||||||
|
ErrCodeUploadFailed = "UPLOAD_FAILED"
|
||||||
|
ErrCodeDownloadFailed = "DOWNLOAD_FAILED"
|
||||||
|
ErrCodeDeleteFailed = "DELETE_FAILED"
|
||||||
|
ErrCodeInvalidInput = "INVALID_INPUT"
|
||||||
|
ErrCodeFileTooLarge = "FILE_TOO_LARGE"
|
||||||
|
ErrCodeUnsupported = "UNSUPPORTED_FILE_TYPE"
|
||||||
|
ErrCodeDuplicate = "DUPLICATE_FILE"
|
||||||
|
ErrCodeAccessDenied = "ACCESS_DENIED"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileStoreError represents a file store operation error
|
||||||
|
type FileStoreError struct {
|
||||||
|
Code string
|
||||||
|
Message string
|
||||||
|
Cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error implements the error interface
|
||||||
|
func (e *FileStoreError) Error() string {
|
||||||
|
if e.Cause != nil {
|
||||||
|
return e.Message + ": " + e.Cause.Error()
|
||||||
|
}
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewError creates a new FileStoreError
|
||||||
|
func NewError(code, message string) *FileStoreError {
|
||||||
|
return &FileStoreError{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
Cause: nil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewErrorWithCause creates a new FileStoreError with an underlying cause
|
||||||
|
func NewErrorWithCause(code, message string, cause error) *FileStoreError {
|
||||||
|
return &FileStoreError{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
Cause: cause,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Predefined errors
|
||||||
|
var (
|
||||||
|
ErrFileNotFound = NewError(ErrCodeNotFound, "file not found")
|
||||||
|
ErrUploadFailed = NewError(ErrCodeUploadFailed, "file upload failed")
|
||||||
|
ErrDownloadFailed = NewError(ErrCodeDownloadFailed, "file download failed")
|
||||||
|
ErrDeleteFailed = NewError(ErrCodeDeleteFailed, "file deletion failed")
|
||||||
|
ErrInvalidInput = NewError(ErrCodeInvalidInput, "invalid input")
|
||||||
|
ErrFileTooLarge = NewError(ErrCodeFileTooLarge, "file size exceeds maximum limit")
|
||||||
|
ErrUnsupportedType = NewError(ErrCodeUnsupported, "unsupported file type")
|
||||||
|
ErrDuplicateFile = NewError(ErrCodeDuplicate, "file already exists")
|
||||||
|
ErrAccessDenied = NewError(ErrCodeAccessDenied, "access denied")
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsNotFound checks if an error is a not found error
|
||||||
|
func IsNotFound(err error) bool {
|
||||||
|
var fse *FileStoreError
|
||||||
|
if errors.As(err, &fse) {
|
||||||
|
return fse.Code == ErrCodeNotFound
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsFileTooLarge checks if an error is a file too large error
|
||||||
|
func IsFileTooLarge(err error) bool {
|
||||||
|
var fse *FileStoreError
|
||||||
|
if errors.As(err, &fse) {
|
||||||
|
return fse.Code == ErrCodeFileTooLarge
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsUnsupportedType checks if an error is an unsupported type error
|
||||||
|
func IsUnsupportedType(err error) bool {
|
||||||
|
var fse *FileStoreError
|
||||||
|
if errors.As(err, &fse) {
|
||||||
|
return fse.Code == ErrCodeUnsupported
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
package filestore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"tm/pkg/logger"
|
||||||
|
"tm/pkg/mongo"
|
||||||
|
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
mongopkg "go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GridFSService implements FileStoreService using MongoDB GridFS
|
||||||
|
type GridFSService struct {
|
||||||
|
database mongopkg.Database
|
||||||
|
logger logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGridFSService creates a new GridFS file store service
|
||||||
|
func NewGridFSService(mongoManager *mongo.ConnectionManager, logger logger.Logger) (*GridFSService, error) {
|
||||||
|
if !mongoManager.IsConnected() {
|
||||||
|
return nil, NewError(ErrCodeUploadFailed, "MongoDB connection not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
database := mongoManager.GetDatabase()
|
||||||
|
if database == nil {
|
||||||
|
return nil, NewError(ErrCodeUploadFailed, "MongoDB database not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("GridFS service initialized successfully", map[string]interface{}{
|
||||||
|
"storage_type": "MongoDB GridFS",
|
||||||
|
})
|
||||||
|
|
||||||
|
return &GridFSService{
|
||||||
|
database: *database,
|
||||||
|
logger: logger,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload stores a file in GridFS
|
||||||
|
func (s *GridFSService) Upload(fileID, filename string, reader io.Reader, options UploadOptions) (string, error) {
|
||||||
|
// Validate input
|
||||||
|
if filename == "" {
|
||||||
|
return "", ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.ContentType == "" {
|
||||||
|
options.ContentType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file size if max size is set
|
||||||
|
if options.MaxSize > 0 {
|
||||||
|
// Read into buffer to check size
|
||||||
|
data, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to read file for size check", map[string]interface{}{
|
||||||
|
"filename": filename,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return "", NewErrorWithCause(ErrCodeUploadFailed, "failed to read file", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if int64(len(data)) > options.MaxSize {
|
||||||
|
s.logger.Warn("File exceeds maximum size", map[string]interface{}{
|
||||||
|
"filename": filename,
|
||||||
|
"size": len(data),
|
||||||
|
"max_size": options.MaxSize,
|
||||||
|
})
|
||||||
|
return "", ErrFileTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
reader = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Create files and chunks collections
|
||||||
|
filesCollection := s.database.Collection("fs.files")
|
||||||
|
chunksCollection := s.database.Collection("fs.chunks")
|
||||||
|
|
||||||
|
// Generate file ID if not provided
|
||||||
|
var objectID bson.ObjectID
|
||||||
|
var err error
|
||||||
|
if fileID != "" {
|
||||||
|
objectID, err = bson.ObjectIDFromHex(fileID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Invalid file ID format", map[string]interface{}{
|
||||||
|
"file_id": fileID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return "", NewErrorWithCause(ErrCodeInvalidInput, "invalid file ID format", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
objectID = bson.NewObjectID()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file data
|
||||||
|
fileData, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to read file data", map[string]interface{}{
|
||||||
|
"filename": filename,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return "", NewErrorWithCause(ErrCodeUploadFailed, "failed to read file", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare metadata
|
||||||
|
metadata := bson.M{
|
||||||
|
"uploaded_at": time.Now().Unix(),
|
||||||
|
"content_type": options.ContentType,
|
||||||
|
}
|
||||||
|
|
||||||
|
if options.UploadedBy != "" {
|
||||||
|
metadata["uploaded_by"] = options.UploadedBy
|
||||||
|
}
|
||||||
|
if options.Category != "" {
|
||||||
|
metadata["category"] = options.Category
|
||||||
|
}
|
||||||
|
if len(options.Tags) > 0 {
|
||||||
|
metadata["tags"] = options.Tags
|
||||||
|
}
|
||||||
|
if options.Description != "" {
|
||||||
|
metadata["description"] = options.Description
|
||||||
|
}
|
||||||
|
if len(options.Extra) > 0 {
|
||||||
|
metadata["extra"] = options.Extra
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split file into chunks (255 KB default chunk size)
|
||||||
|
chunkSize := 255 * 1024
|
||||||
|
numChunks := (len(fileData) + chunkSize - 1) / chunkSize
|
||||||
|
|
||||||
|
// Insert chunks
|
||||||
|
for i := 0; i < numChunks; i++ {
|
||||||
|
start := i * chunkSize
|
||||||
|
end := start + chunkSize
|
||||||
|
if end > len(fileData) {
|
||||||
|
end = len(fileData)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunk := bson.M{
|
||||||
|
"files_id": objectID,
|
||||||
|
"n": int32(i),
|
||||||
|
"data": fileData[start:end],
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := chunksCollection.InsertOne(ctx, chunk)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to insert chunk", map[string]interface{}{
|
||||||
|
"filename": filename,
|
||||||
|
"chunk": i,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return "", NewErrorWithCause(ErrCodeUploadFailed, "failed to store file chunks", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert file record
|
||||||
|
fileRecord := bson.M{
|
||||||
|
"_id": objectID,
|
||||||
|
"filename": filename,
|
||||||
|
"uploadDate": time.Now(),
|
||||||
|
"chunkSize": int32(chunkSize),
|
||||||
|
"length": int32(len(fileData)),
|
||||||
|
"md5": "", // MongoDB would calculate this, we can leave empty
|
||||||
|
"metadata": metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = filesCollection.InsertOne(ctx, fileRecord)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to insert file record", map[string]interface{}{
|
||||||
|
"filename": filename,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return "", NewErrorWithCause(ErrCodeUploadFailed, "failed to upload file", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadedFileID := objectID.Hex()
|
||||||
|
s.logger.Info("File uploaded successfully to GridFS", map[string]interface{}{
|
||||||
|
"file_id": uploadedFileID,
|
||||||
|
"filename": filename,
|
||||||
|
"content_type": options.ContentType,
|
||||||
|
"uploaded_by": options.UploadedBy,
|
||||||
|
"category": options.Category,
|
||||||
|
})
|
||||||
|
|
||||||
|
return uploadedFileID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download retrieves a file from GridFS
|
||||||
|
func (s *GridFSService) Download(fileID string) (io.ReadCloser, *FileInfo, error) {
|
||||||
|
if fileID == "" {
|
||||||
|
return nil, nil, ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
objectID, err := bson.ObjectIDFromHex(fileID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Invalid file ID format", map[string]interface{}{
|
||||||
|
"file_id": fileID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, nil, NewErrorWithCause(ErrCodeInvalidInput, "invalid file ID format", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Get file info first
|
||||||
|
fileInfo, err := s.GetFileInfo(fileID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all chunks for this file
|
||||||
|
chunksCollection := s.database.Collection("fs.chunks")
|
||||||
|
findOpts := options.Find().SetSort(bson.D{{Key: "n", Value: 1}})
|
||||||
|
cursor, err := chunksCollection.Find(ctx, bson.M{"files_id": objectID}, findOpts)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to find chunks", map[string]interface{}{
|
||||||
|
"file_id": fileID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, nil, NewErrorWithCause(ErrCodeDownloadFailed, "failed to download file", err)
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
// Read all chunks and assemble file
|
||||||
|
var chunks []bson.M
|
||||||
|
if err := cursor.All(ctx, &chunks); err != nil {
|
||||||
|
s.logger.Error("Failed to read chunks", map[string]interface{}{
|
||||||
|
"file_id": fileID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, nil, NewErrorWithCause(ErrCodeDownloadFailed, "failed to read file data", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assemble file from chunks
|
||||||
|
var fileData bytes.Buffer
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
data, ok := chunk["data"].(bson.Binary)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, NewError(ErrCodeDownloadFailed, "invalid chunk data format")
|
||||||
|
}
|
||||||
|
fileData.Write(data.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("File downloaded successfully from GridFS", map[string]interface{}{
|
||||||
|
"file_id": fileID,
|
||||||
|
"filename": fileInfo.Filename,
|
||||||
|
})
|
||||||
|
|
||||||
|
return io.NopCloser(&fileData), fileInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a file from GridFS
|
||||||
|
func (s *GridFSService) Delete(fileID string) error {
|
||||||
|
if fileID == "" {
|
||||||
|
return ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
objectID, err := bson.ObjectIDFromHex(fileID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Invalid file ID format", map[string]interface{}{
|
||||||
|
"file_id": fileID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return NewErrorWithCause(ErrCodeInvalidInput, "invalid file ID format", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Delete file record
|
||||||
|
filesCollection := s.database.Collection("fs.files")
|
||||||
|
_, err = filesCollection.DeleteOne(ctx, bson.M{"_id": objectID})
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to delete file record", map[string]interface{}{
|
||||||
|
"file_id": fileID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return NewErrorWithCause(ErrCodeDeleteFailed, "failed to delete file", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete associated chunks
|
||||||
|
chunksCollection := s.database.Collection("fs.chunks")
|
||||||
|
_, err = chunksCollection.DeleteMany(ctx, bson.M{"files_id": objectID})
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to delete file chunks", map[string]interface{}{
|
||||||
|
"file_id": fileID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return NewErrorWithCause(ErrCodeDeleteFailed, "failed to delete file chunks", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("File deleted successfully from GridFS", map[string]interface{}{
|
||||||
|
"file_id": fileID,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileInfo retrieves metadata for a file
|
||||||
|
func (s *GridFSService) GetFileInfo(fileID string) (*FileInfo, error) {
|
||||||
|
if fileID == "" {
|
||||||
|
return nil, ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
objectID, err := bson.ObjectIDFromHex(fileID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Invalid file ID format", map[string]interface{}{
|
||||||
|
"file_id": fileID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, NewErrorWithCause(ErrCodeInvalidInput, "invalid file ID format", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Get file record from fs.files collection
|
||||||
|
filesCollection := s.database.Collection("fs.files")
|
||||||
|
var fileRecord bson.M
|
||||||
|
err = filesCollection.FindOne(ctx, bson.M{"_id": objectID}).Decode(&fileRecord)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to find file", map[string]interface{}{
|
||||||
|
"file_id": fileID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, NewErrorWithCause(ErrCodeNotFound, "file not found", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract upload date
|
||||||
|
var uploadDate time.Time
|
||||||
|
if ud, ok := fileRecord["uploadDate"].(time.Time); ok {
|
||||||
|
uploadDate = ud
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract metadata
|
||||||
|
var metadata map[string]interface{}
|
||||||
|
if md, ok := fileRecord["metadata"].(bson.M); ok {
|
||||||
|
metadata = md
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInfo := &FileInfo{
|
||||||
|
FileID: fileID,
|
||||||
|
Filename: fileRecord["filename"].(string),
|
||||||
|
ContentType: "application/octet-stream",
|
||||||
|
Size: int64(fileRecord["length"].(int32)),
|
||||||
|
UploadedAt: uploadDate,
|
||||||
|
Metadata: metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract metadata fields if they exist
|
||||||
|
if metadata != nil {
|
||||||
|
if ct, ok := metadata["content_type"].(string); ok {
|
||||||
|
fileInfo.ContentType = ct
|
||||||
|
}
|
||||||
|
if ub, ok := metadata["uploaded_by"].(string); ok {
|
||||||
|
fileInfo.UploadedBy = ub
|
||||||
|
}
|
||||||
|
if cat, ok := metadata["category"].(string); ok {
|
||||||
|
fileInfo.Category = cat
|
||||||
|
}
|
||||||
|
if tags, ok := metadata["tags"].([]interface{}); ok {
|
||||||
|
fileInfo.Tags = make([]string, len(tags))
|
||||||
|
for i, tag := range tags {
|
||||||
|
if tagStr, ok := tag.(string); ok {
|
||||||
|
fileInfo.Tags[i] = tagStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if desc, ok := metadata["description"].(string); ok {
|
||||||
|
fileInfo.Description = desc
|
||||||
|
}
|
||||||
|
if extra, ok := metadata["extra"].(bson.M); ok {
|
||||||
|
fileInfo.Extra = make(map[string]string)
|
||||||
|
for k, v := range extra {
|
||||||
|
if vs, ok := v.(string); ok {
|
||||||
|
fileInfo.Extra[k] = vs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFiles lists files by category with optional filtering
|
||||||
|
func (s *GridFSService) ListFiles(category string, tags []string, limit int64, skip int64) ([]*FileInfo, int64, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if limit > 1000 {
|
||||||
|
limit = 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Build filter
|
||||||
|
filter := bson.M{}
|
||||||
|
if category != "" {
|
||||||
|
filter["metadata.category"] = category
|
||||||
|
}
|
||||||
|
if len(tags) > 0 {
|
||||||
|
filter["metadata.tags"] = bson.M{"$in": tags}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find files
|
||||||
|
filesCollection := s.database.Collection("fs.files")
|
||||||
|
|
||||||
|
// Count total
|
||||||
|
total, err := filesCollection.CountDocuments(ctx, filter)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to count files", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, 0, NewErrorWithCause(ErrCodeDownloadFailed, "failed to count files", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find and list
|
||||||
|
opts := options.Find().SetSkip(skip).SetLimit(limit)
|
||||||
|
|
||||||
|
cursor, err := filesCollection.Find(ctx, filter, opts)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to list files", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, 0, NewErrorWithCause(ErrCodeDownloadFailed, "failed to list files", err)
|
||||||
|
}
|
||||||
|
defer cursor.Close(ctx)
|
||||||
|
|
||||||
|
var fileRecords []bson.M
|
||||||
|
if err := cursor.All(ctx, &fileRecords); err != nil {
|
||||||
|
s.logger.Error("Failed to decode files", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return nil, 0, NewErrorWithCause(ErrCodeDownloadFailed, "failed to decode files", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to FileInfo
|
||||||
|
var files []*FileInfo
|
||||||
|
for _, record := range fileRecords {
|
||||||
|
fileID := record["_id"].(bson.ObjectID).Hex()
|
||||||
|
|
||||||
|
var uploadDate time.Time
|
||||||
|
if ud, ok := record["uploadDate"].(time.Time); ok {
|
||||||
|
uploadDate = ud
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata map[string]interface{}
|
||||||
|
if md, ok := record["metadata"].(bson.M); ok {
|
||||||
|
metadata = md
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInfo := &FileInfo{
|
||||||
|
FileID: fileID,
|
||||||
|
Filename: record["filename"].(string),
|
||||||
|
ContentType: "application/octet-stream",
|
||||||
|
Size: int64(record["length"].(int32)),
|
||||||
|
UploadedAt: uploadDate,
|
||||||
|
Metadata: metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract metadata fields
|
||||||
|
if metadata != nil {
|
||||||
|
if ct, ok := metadata["content_type"].(string); ok {
|
||||||
|
fileInfo.ContentType = ct
|
||||||
|
}
|
||||||
|
if ub, ok := metadata["uploaded_by"].(string); ok {
|
||||||
|
fileInfo.UploadedBy = ub
|
||||||
|
}
|
||||||
|
if cat, ok := metadata["category"].(string); ok {
|
||||||
|
fileInfo.Category = cat
|
||||||
|
}
|
||||||
|
if tags, ok := metadata["tags"].([]interface{}); ok {
|
||||||
|
fileInfo.Tags = make([]string, len(tags))
|
||||||
|
for i, tag := range tags {
|
||||||
|
if tagStr, ok := tag.(string); ok {
|
||||||
|
fileInfo.Tags[i] = tagStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
files = append(files, fileInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
return files, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exists checks if a file exists
|
||||||
|
func (s *GridFSService) Exists(fileID string) (bool, error) {
|
||||||
|
if fileID == "" {
|
||||||
|
return false, ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.GetFileInfo(fileID)
|
||||||
|
if err != nil {
|
||||||
|
if IsNotFound(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
package filestore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"tm/pkg/logger"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler handles HTTP requests for file operations
|
||||||
|
type Handler struct {
|
||||||
|
service FileStoreService
|
||||||
|
logger logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler creates a new file handler
|
||||||
|
func NewHandler(service FileStoreService, logger logger.Logger) *Handler {
|
||||||
|
return &Handler{
|
||||||
|
service: service,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadResponse represents the response for a successful upload
|
||||||
|
type UploadResponse struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileInfoResponse represents file information in API response
|
||||||
|
type FileInfoResponse struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
UploadedBy string `json:"uploaded_by,omitempty"`
|
||||||
|
UploadedAt time.Time `json:"uploaded_at"`
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Extra map[string]string `json:"extra,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFilesResponse represents the response for list files operation
|
||||||
|
type ListFilesResponse struct {
|
||||||
|
Files []FileInfoResponse `json:"files"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Limit int64 `json:"limit"`
|
||||||
|
Skip int64 `json:"skip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorResponse represents an error response
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload handles file upload
|
||||||
|
// @Summary Upload a file
|
||||||
|
// @Description Upload a file to GridFS storage
|
||||||
|
// @Tags Files
|
||||||
|
// @Accept multipart/form-data
|
||||||
|
// @Produce json
|
||||||
|
// @Param file formData file true "File to upload"
|
||||||
|
// @Param category formData string false "File category"
|
||||||
|
// @Param tags formData []string false "File tags"
|
||||||
|
// @Param description formData string false "File description"
|
||||||
|
// @Success 200 {object} UploadResponse
|
||||||
|
// @Failure 400 {object} ErrorResponse
|
||||||
|
// @Failure 413 {object} ErrorResponse "File too large"
|
||||||
|
// @Failure 500 {object} ErrorResponse
|
||||||
|
// @Router /api/v1/files/upload [post]
|
||||||
|
func (h *Handler) Upload(c echo.Context) error {
|
||||||
|
// Get file from request
|
||||||
|
file, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("No file provided in upload request", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return c.JSON(http.StatusBadRequest, ErrorResponse{
|
||||||
|
Error: "No file provided",
|
||||||
|
Code: ErrCodeInvalidInput,
|
||||||
|
Message: "Please provide a file to upload",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open uploaded file
|
||||||
|
src, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to open uploaded file", map[string]interface{}{
|
||||||
|
"filename": file.Filename,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return c.JSON(http.StatusInternalServerError, ErrorResponse{
|
||||||
|
Error: "Failed to open file",
|
||||||
|
Code: ErrCodeUploadFailed,
|
||||||
|
Message: "Unable to read the uploaded file",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
// Get form data
|
||||||
|
category := c.FormValue("category")
|
||||||
|
tags := c.Request().Form["tags"]
|
||||||
|
description := c.FormValue("description")
|
||||||
|
uploadedBy := h.getUploaderID(c)
|
||||||
|
if uploadedBy == "" {
|
||||||
|
return c.JSON(http.StatusUnauthorized, ErrorResponse{
|
||||||
|
Error: "Unauthorized",
|
||||||
|
Code: ErrCodeAccessDenied,
|
||||||
|
Message: "Authenticated user not found in request context",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect content type if not provided
|
||||||
|
contentType := file.Header.Get("Content-Type")
|
||||||
|
if contentType == "" {
|
||||||
|
ext := filepath.Ext(file.Filename)
|
||||||
|
if detected := mime.TypeByExtension(ext); detected != "" {
|
||||||
|
contentType = detected
|
||||||
|
} else {
|
||||||
|
contentType = "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contentType = strings.TrimSpace(strings.Split(contentType, ";")[0])
|
||||||
|
|
||||||
|
validator := NewFileValidator(100*1024*1024, SupportedMimeTypes)
|
||||||
|
if err := validator.ValidateFile(file.Filename, contentType, file.Size); err != nil {
|
||||||
|
if IsFileTooLarge(err) {
|
||||||
|
return c.JSON(http.StatusRequestEntityTooLarge, ErrorResponse{
|
||||||
|
Error: "File is too large",
|
||||||
|
Code: ErrCodeFileTooLarge,
|
||||||
|
Message: "Maximum file size is 100 MB",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if IsUnsupportedType(err) {
|
||||||
|
return c.JSON(http.StatusBadRequest, ErrorResponse{
|
||||||
|
Error: "Unsupported file type",
|
||||||
|
Code: ErrCodeUnsupported,
|
||||||
|
Message: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusBadRequest, ErrorResponse{
|
||||||
|
Error: "Invalid file",
|
||||||
|
Code: ErrCodeInvalidInput,
|
||||||
|
Message: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create upload options
|
||||||
|
opts := UploadOptions{
|
||||||
|
Filename: file.Filename,
|
||||||
|
ContentType: contentType,
|
||||||
|
UploadedBy: uploadedBy,
|
||||||
|
Category: category,
|
||||||
|
Tags: tags,
|
||||||
|
Description: description,
|
||||||
|
MaxSize: 100 * 1024 * 1024, // 100 MB max
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload file
|
||||||
|
fileID, err := h.service.Upload("", file.Filename, src, opts)
|
||||||
|
if err != nil {
|
||||||
|
if IsFileTooLarge(err) {
|
||||||
|
h.logger.Warn("File upload failed: file too large", map[string]interface{}{
|
||||||
|
"filename": file.Filename,
|
||||||
|
"size": file.Size,
|
||||||
|
})
|
||||||
|
return c.JSON(http.StatusRequestEntityTooLarge, ErrorResponse{
|
||||||
|
Error: "File is too large",
|
||||||
|
Code: ErrCodeFileTooLarge,
|
||||||
|
Message: "Maximum file size is 100 MB",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Error("File upload failed", map[string]interface{}{
|
||||||
|
"filename": file.Filename,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return c.JSON(http.StatusInternalServerError, ErrorResponse{
|
||||||
|
Error: "Upload failed",
|
||||||
|
Code: ErrCodeUploadFailed,
|
||||||
|
Message: "Failed to upload file to storage",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, UploadResponse{
|
||||||
|
FileID: fileID,
|
||||||
|
Filename: file.Filename,
|
||||||
|
ContentType: contentType,
|
||||||
|
Size: file.Size,
|
||||||
|
Message: "File uploaded successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download handles file download
|
||||||
|
// @Summary Download a file
|
||||||
|
// @Description Download a file by its ID
|
||||||
|
// @Tags Files
|
||||||
|
// @Produce application/octet-stream
|
||||||
|
// @Param file_id path string true "File ID"
|
||||||
|
// @Success 200 {file} bytes
|
||||||
|
// @Failure 404 {object} ErrorResponse
|
||||||
|
// @Failure 500 {object} ErrorResponse
|
||||||
|
// @Router /api/v1/files/{file_id}/download [get]
|
||||||
|
func (h *Handler) Download(c echo.Context) error {
|
||||||
|
fileID := c.Param("file_id")
|
||||||
|
if fileID == "" {
|
||||||
|
return c.JSON(http.StatusBadRequest, ErrorResponse{
|
||||||
|
Error: "File ID is required",
|
||||||
|
Code: ErrCodeInvalidInput,
|
||||||
|
Message: "Please provide a valid file ID",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download file
|
||||||
|
reader, fileInfo, err := h.service.Download(fileID)
|
||||||
|
if err != nil {
|
||||||
|
if IsNotFound(err) {
|
||||||
|
h.logger.Warn("File not found", map[string]interface{}{
|
||||||
|
"file_id": fileID,
|
||||||
|
})
|
||||||
|
return c.JSON(http.StatusNotFound, ErrorResponse{
|
||||||
|
Error: "File not found",
|
||||||
|
Code: ErrCodeNotFound,
|
||||||
|
Message: "The requested file does not exist",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Error("File download failed", map[string]interface{}{
|
||||||
|
"file_id": fileID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return c.JSON(http.StatusInternalServerError, ErrorResponse{
|
||||||
|
Error: "Download failed",
|
||||||
|
Code: ErrCodeDownloadFailed,
|
||||||
|
Message: "Failed to download file",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
// Set response headers
|
||||||
|
c.Response().Header().Set("Content-Type", fileInfo.ContentType)
|
||||||
|
c.Response().Header().Set("Content-Length", strconv.FormatInt(fileInfo.Size, 10))
|
||||||
|
c.Response().Header().Set("Content-Disposition", `attachment; filename="`+fileInfo.Filename+`"`)
|
||||||
|
c.Response().Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
|
||||||
|
// Write file to response
|
||||||
|
return c.Stream(http.StatusOK, fileInfo.ContentType, reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) getUploaderID(c echo.Context) string {
|
||||||
|
if userID, ok := c.Get("user_id").(string); ok && userID != "" {
|
||||||
|
return userID
|
||||||
|
}
|
||||||
|
if customerID, ok := c.Get("customer_id").(string); ok && customerID != "" {
|
||||||
|
return customerID
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInfo retrieves file information
|
||||||
|
// @Summary Get file information
|
||||||
|
// @Description Get metadata for a file by its ID
|
||||||
|
// @Tags Files
|
||||||
|
// @Produce json
|
||||||
|
// @Param file_id path string true "File ID"
|
||||||
|
// @Success 200 {object} FileInfoResponse
|
||||||
|
// @Failure 404 {object} ErrorResponse
|
||||||
|
// @Failure 500 {object} ErrorResponse
|
||||||
|
// @Router /api/v1/files/{file_id}/info [get]
|
||||||
|
func (h *Handler) GetInfo(c echo.Context) error {
|
||||||
|
fileID := c.Param("file_id")
|
||||||
|
if fileID == "" {
|
||||||
|
return c.JSON(http.StatusBadRequest, ErrorResponse{
|
||||||
|
Error: "File ID is required",
|
||||||
|
Code: ErrCodeInvalidInput,
|
||||||
|
Message: "Please provide a valid file ID",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file info
|
||||||
|
fileInfo, err := h.service.GetFileInfo(fileID)
|
||||||
|
if err != nil {
|
||||||
|
if IsNotFound(err) {
|
||||||
|
return c.JSON(http.StatusNotFound, ErrorResponse{
|
||||||
|
Error: "File not found",
|
||||||
|
Code: ErrCodeNotFound,
|
||||||
|
Message: "The requested file does not exist",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Error("Failed to get file info", map[string]interface{}{
|
||||||
|
"file_id": fileID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return c.JSON(http.StatusInternalServerError, ErrorResponse{
|
||||||
|
Error: "Failed to get file info",
|
||||||
|
Code: ErrCodeDownloadFailed,
|
||||||
|
Message: "Unable to retrieve file information",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, FileInfoResponse{
|
||||||
|
FileID: fileInfo.FileID,
|
||||||
|
Filename: fileInfo.Filename,
|
||||||
|
ContentType: fileInfo.ContentType,
|
||||||
|
Size: fileInfo.Size,
|
||||||
|
UploadedBy: fileInfo.UploadedBy,
|
||||||
|
UploadedAt: fileInfo.UploadedAt,
|
||||||
|
Category: fileInfo.Category,
|
||||||
|
Tags: fileInfo.Tags,
|
||||||
|
Description: fileInfo.Description,
|
||||||
|
Extra: fileInfo.Extra,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a file
|
||||||
|
// @Summary Delete a file
|
||||||
|
// @Description Delete a file by its ID
|
||||||
|
// @Tags Files
|
||||||
|
// @Param file_id path string true "File ID"
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Failure 404 {object} ErrorResponse
|
||||||
|
// @Failure 500 {object} ErrorResponse
|
||||||
|
// @Router /api/v1/files/{file_id} [delete]
|
||||||
|
func (h *Handler) Delete(c echo.Context) error {
|
||||||
|
fileID := c.Param("file_id")
|
||||||
|
if fileID == "" {
|
||||||
|
return c.JSON(http.StatusBadRequest, ErrorResponse{
|
||||||
|
Error: "File ID is required",
|
||||||
|
Code: ErrCodeInvalidInput,
|
||||||
|
Message: "Please provide a valid file ID",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete file
|
||||||
|
err := h.service.Delete(fileID)
|
||||||
|
if err != nil {
|
||||||
|
if IsNotFound(err) {
|
||||||
|
return c.JSON(http.StatusNotFound, ErrorResponse{
|
||||||
|
Error: "File not found",
|
||||||
|
Code: ErrCodeNotFound,
|
||||||
|
Message: "The requested file does not exist",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Error("File deletion failed", map[string]interface{}{
|
||||||
|
"file_id": fileID,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return c.JSON(http.StatusInternalServerError, ErrorResponse{
|
||||||
|
Error: "Deletion failed",
|
||||||
|
Code: ErrCodeDeleteFailed,
|
||||||
|
Message: "Failed to delete file",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{
|
||||||
|
"message": "File deleted successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFiles lists files by category
|
||||||
|
// @Summary List files
|
||||||
|
// @Description List files by category with optional filtering
|
||||||
|
// @Tags Files
|
||||||
|
// @Produce json
|
||||||
|
// @Param category query string false "File category"
|
||||||
|
// @Param tags query []string false "File tags"
|
||||||
|
// @Param limit query int false "Number of files to return (default 50, max 1000)"
|
||||||
|
// @Param skip query int false "Number of files to skip (default 0)"
|
||||||
|
// @Success 200 {object} ListFilesResponse
|
||||||
|
// @Failure 500 {object} ErrorResponse
|
||||||
|
// @Router /api/v1/files [get]
|
||||||
|
func (h *Handler) ListFiles(c echo.Context) error {
|
||||||
|
category := c.QueryParam("category")
|
||||||
|
tags := c.QueryParams()["tags"]
|
||||||
|
limit := int64(50)
|
||||||
|
skip := int64(0)
|
||||||
|
|
||||||
|
// Parse pagination params
|
||||||
|
if l := c.QueryParam("limit"); l != "" {
|
||||||
|
if val, err := strconv.ParseInt(l, 10, 64); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, ErrorResponse{
|
||||||
|
Error: "Invalid limit parameter",
|
||||||
|
Code: ErrCodeInvalidInput,
|
||||||
|
Message: "Limit must be a valid integer",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
limit = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s := c.QueryParam("skip"); s != "" {
|
||||||
|
if val, err := strconv.ParseInt(s, 10, 64); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, ErrorResponse{
|
||||||
|
Error: "Invalid skip parameter",
|
||||||
|
Code: ErrCodeInvalidInput,
|
||||||
|
Message: "Skip must be a valid integer",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
skip = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List files
|
||||||
|
files, total, err := h.service.ListFiles(category, tags, limit, skip)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("Failed to list files", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return c.JSON(http.StatusInternalServerError, ErrorResponse{
|
||||||
|
Error: "Failed to list files",
|
||||||
|
Code: ErrCodeDownloadFailed,
|
||||||
|
Message: "Unable to retrieve file list",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to response format
|
||||||
|
fileResponses := make([]FileInfoResponse, len(files))
|
||||||
|
for i, f := range files {
|
||||||
|
fileResponses[i] = FileInfoResponse{
|
||||||
|
FileID: f.FileID,
|
||||||
|
Filename: f.Filename,
|
||||||
|
ContentType: f.ContentType,
|
||||||
|
Size: f.Size,
|
||||||
|
UploadedBy: f.UploadedBy,
|
||||||
|
UploadedAt: f.UploadedAt,
|
||||||
|
Category: f.Category,
|
||||||
|
Tags: f.Tags,
|
||||||
|
Description: f.Description,
|
||||||
|
Extra: f.Extra,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, ListFilesResponse{
|
||||||
|
Files: fileResponses,
|
||||||
|
Total: total,
|
||||||
|
Limit: limit,
|
||||||
|
Skip: skip,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package filestore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileMetadata represents metadata for a stored file
|
||||||
|
type FileMetadata struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
UploadedBy string `json:"uploaded_by"`
|
||||||
|
UploadedAt time.Time `json:"uploaded_at"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Extra map[string]string `json:"extra,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadOptions contains options for file upload
|
||||||
|
type UploadOptions struct {
|
||||||
|
Filename string
|
||||||
|
ContentType string
|
||||||
|
UploadedBy string
|
||||||
|
Category string
|
||||||
|
Tags []string
|
||||||
|
Description string
|
||||||
|
Extra map[string]string
|
||||||
|
MaxSize int64 // Maximum file size in bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadOptions contains options for file download
|
||||||
|
type DownloadOptions struct {
|
||||||
|
FileID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileInfo represents information about a stored file
|
||||||
|
type FileInfo struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
UploadedBy string `json:"uploaded_by"`
|
||||||
|
UploadedAt time.Time `json:"uploaded_at"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Extra map[string]string `json:"extra,omitempty"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileStoreService defines the interface for file storage operations
|
||||||
|
type FileStoreService interface {
|
||||||
|
// Upload stores a file and returns the file ID
|
||||||
|
Upload(fileID, filename string, reader io.Reader, options UploadOptions) (string, error)
|
||||||
|
|
||||||
|
// Download retrieves a file by ID
|
||||||
|
Download(fileID string) (io.ReadCloser, *FileInfo, error)
|
||||||
|
|
||||||
|
// Delete removes a file by ID
|
||||||
|
Delete(fileID string) error
|
||||||
|
|
||||||
|
// GetFileInfo retrieves metadata for a file
|
||||||
|
GetFileInfo(fileID string) (*FileInfo, error)
|
||||||
|
|
||||||
|
// ListFiles lists files by category with optional filtering
|
||||||
|
ListFiles(category string, tags []string, limit int64, skip int64) ([]*FileInfo, int64, error)
|
||||||
|
|
||||||
|
// Exists checks if a file exists
|
||||||
|
Exists(fileID string) (bool, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package filestore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"mime"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SupportedMimeTypes defines the allowed MIME types for uploads
|
||||||
|
var SupportedMimeTypes = map[string]bool{
|
||||||
|
"image/jpeg": true,
|
||||||
|
"image/png": true,
|
||||||
|
"image/gif": true,
|
||||||
|
"image/webp": true,
|
||||||
|
"image/svg+xml": true,
|
||||||
|
"application/pdf": true,
|
||||||
|
"application/msword": true,
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true,
|
||||||
|
"application/vnd.ms-excel": true,
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true,
|
||||||
|
"text/plain": true,
|
||||||
|
"text/csv": true,
|
||||||
|
"application/json": true,
|
||||||
|
"application/zip": true,
|
||||||
|
"application/x-rar-compressed": true,
|
||||||
|
"application/x-7z-compressed": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfileImageMimeTypes defines allowed MIME types for profile images
|
||||||
|
var ProfileImageMimeTypes = map[string]bool{
|
||||||
|
"image/jpeg": true,
|
||||||
|
"image/png": true,
|
||||||
|
"image/gif": true,
|
||||||
|
"image/webp": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileValidator validates file uploads
|
||||||
|
type FileValidator struct {
|
||||||
|
MaxSize int64
|
||||||
|
AllowedMimeTypes map[string]bool
|
||||||
|
AllowedExtensions map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileValidator creates a new file validator
|
||||||
|
func NewFileValidator(maxSize int64, mimeTypes map[string]bool) *FileValidator {
|
||||||
|
return &FileValidator{
|
||||||
|
MaxSize: maxSize,
|
||||||
|
AllowedMimeTypes: mimeTypes,
|
||||||
|
AllowedExtensions: make(map[string]bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProfileImageValidator creates a validator for profile images
|
||||||
|
func NewProfileImageValidator() *FileValidator {
|
||||||
|
return &FileValidator{
|
||||||
|
MaxSize: 5 * 1024 * 1024, // 5 MB
|
||||||
|
AllowedMimeTypes: ProfileImageMimeTypes,
|
||||||
|
AllowedExtensions: map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateFileSize validates that the file size is within limits
|
||||||
|
func (v *FileValidator) ValidateFileSize(size int64) error {
|
||||||
|
if size <= 0 {
|
||||||
|
return NewError(ErrCodeInvalidInput, "file size must be greater than 0")
|
||||||
|
}
|
||||||
|
if size > v.MaxSize {
|
||||||
|
return NewError(ErrCodeFileTooLarge, "file size exceeds maximum limit")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateMimeType validates that the MIME type is supported
|
||||||
|
func (v *FileValidator) ValidateMimeType(contentType string) error {
|
||||||
|
if contentType == "" {
|
||||||
|
return NewError(ErrCodeInvalidInput, "content type is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !v.AllowedMimeTypes[contentType] {
|
||||||
|
return NewError(ErrCodeUnsupported, "unsupported file type: "+contentType)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateFilename validates that the filename is valid and has an allowed extension
|
||||||
|
func (v *FileValidator) ValidateFilename(filename string) error {
|
||||||
|
if filename == "" {
|
||||||
|
return NewError(ErrCodeInvalidInput, "filename is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for path traversal
|
||||||
|
if strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") {
|
||||||
|
return NewError(ErrCodeInvalidInput, "invalid filename: path traversal detected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check extension if configured
|
||||||
|
if len(v.AllowedExtensions) > 0 {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filename))
|
||||||
|
if !v.AllowedExtensions[ext] {
|
||||||
|
return NewError(ErrCodeUnsupported, "unsupported file extension: "+ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateFile validates all file properties
|
||||||
|
func (v *FileValidator) ValidateFile(filename string, contentType string, size int64) error {
|
||||||
|
if err := v.ValidateFilename(filename); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := v.ValidateFileSize(size); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := v.ValidateMimeType(contentType); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectMimeType detects MIME type from filename
|
||||||
|
func DetectMimeType(filename string) string {
|
||||||
|
ext := filepath.Ext(filename)
|
||||||
|
if detected := mime.TypeByExtension(ext); detected != "" {
|
||||||
|
return detected
|
||||||
|
}
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeFilename removes potentially dangerous characters from filename
|
||||||
|
func SanitizeFilename(filename string) string {
|
||||||
|
// Remove path separators
|
||||||
|
filename = strings.ReplaceAll(filename, "/", "_")
|
||||||
|
filename = strings.ReplaceAll(filename, "\\", "_")
|
||||||
|
|
||||||
|
// Remove null bytes
|
||||||
|
filename = strings.ReplaceAll(filename, "\x00", "")
|
||||||
|
|
||||||
|
// Remove other potentially dangerous characters
|
||||||
|
dangerous := []string{"..", "~", "`", "!", "@", "#", "$", "%", "^", "&", "*", "?", ":", ";", "<", ">", "|"}
|
||||||
|
for _, char := range dangerous {
|
||||||
|
filename = strings.ReplaceAll(filename, char, "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim whitespace
|
||||||
|
filename = strings.TrimSpace(filename)
|
||||||
|
|
||||||
|
// Ensure filename is not empty
|
||||||
|
if filename == "" {
|
||||||
|
filename = "file"
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename
|
||||||
|
}
|
||||||
@@ -204,6 +204,25 @@ func (cm *ConnectionManager) Close(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsConnected checks if MongoDB connection is active
|
||||||
|
func (cm *ConnectionManager) IsConnected() bool {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := cm.Ping(ctx)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDatabase returns the MongoDB database instance
|
||||||
|
func (cm *ConnectionManager) GetDatabase() *mongo.Database {
|
||||||
|
return cm.database
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClient returns the MongoDB client instance
|
||||||
|
func (cm *ConnectionManager) GetClient() *mongo.Client {
|
||||||
|
return cm.client
|
||||||
|
}
|
||||||
|
|
||||||
// Ping pings the MongoDB server
|
// Ping pings the MongoDB server
|
||||||
func (cm *ConnectionManager) Ping(ctx context.Context) error {
|
func (cm *ConnectionManager) Ping(ctx context.Context) error {
|
||||||
if err := cm.client.Ping(ctx, readpref.Primary()); err != nil {
|
if err := cm.client.Ping(ctx, readpref.Primary()); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user