package service import ( "context" "errors" "fmt" "mime" "mime/multipart" "path/filepath" "strings" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/utils" "github.com/google/uuid" ) const ( defaultDocumentPathLimit = 50 defaultDocumentKeyPrefix = "docs" maxDocumentNameLength = 50 ) type DocumentService interface { UploadDocuments(ctx context.Context, req DocumentUploadRequest) ([]DocumentUploadResult, error) ListByTarget(ctx context.Context, documentableType string, documentableID uint64) ([]entity.Document, error) DeleteDocuments(ctx context.Context, ids []uint, removeFromStorage bool) error DeleteByTarget(ctx context.Context, documentableType string, documentableID uint64, removeFromStorage bool) error PublicURL(document entity.Document) string } type DocumentUploadRequest struct { DocumentableType string DocumentableID uint64 CreatedBy *uint Files []DocumentFile } type DocumentFile struct { File *multipart.FileHeader Type string Index *int } type DocumentUploadResult struct { Document entity.Document URL string Index *int } type DocumentServiceOption func(*documentService) type documentService struct { repo commonRepo.DocumentRepository storage DocumentStorage keyPrefix string maxPathLength int } func NewDocumentService(repo commonRepo.DocumentRepository, storage DocumentStorage, opts ...DocumentServiceOption) DocumentService { svc := &documentService{ repo: repo, storage: storage, keyPrefix: defaultDocumentKeyPrefix, maxPathLength: defaultDocumentPathLimit, } for _, opt := range opts { opt(svc) } return svc } func NewDocumentServiceFromConfig(ctx context.Context, repo commonRepo.DocumentRepository) (DocumentService, error) { if repo == nil { return nil, errors.New("document repository is required") } if strings.TrimSpace(config.S3Bucket) == "" { return nil, errors.New("S3_BUCKET is not configured") } storage, err := NewS3DocumentStorage(ctx, S3DocumentStorageConfig{ Region: config.S3Region, Bucket: config.S3Bucket, AccessKey: config.S3AccessKey, SecretKey: config.S3SecretKey, Endpoint: config.S3Endpoint, BaseURL: config.S3PublicBaseURL, ForcePathStyle: config.S3ForcePathStyle, }) if err != nil { return nil, err } prefix := config.S3DocumentKeyPrefix if prefix == "" { prefix = defaultDocumentKeyPrefix } return NewDocumentService( repo, storage, WithDocumentKeyPrefix(prefix), WithDocumentPathLimit(defaultDocumentPathLimit), ), nil } func WithDocumentKeyPrefix(prefix string) DocumentServiceOption { return func(svc *documentService) { prefix = strings.Trim(prefix, "/") if prefix == "" { prefix = defaultDocumentKeyPrefix } svc.keyPrefix = prefix } } func WithDocumentPathLimit(limit int) DocumentServiceOption { return func(svc *documentService) { if limit > 0 { svc.maxPathLength = limit } } } func (s *documentService) UploadDocuments(ctx context.Context, req DocumentUploadRequest) ([]DocumentUploadResult, error) { if s.repo == nil { return nil, errors.New("document repository not configured") } if s.storage == nil { return nil, errors.New("document storage not configured") } documentableType := strings.ToUpper(strings.TrimSpace(req.DocumentableType)) if documentableType == "" { return nil, errors.New("documentable type is required") } if req.DocumentableID == 0 { return nil, errors.New("documentable id is required") } if len(req.Files) == 0 { return nil, errors.New("no files to upload") } var createdBy *uint if req.CreatedBy != nil && *req.CreatedBy != 0 { idCopy := *req.CreatedBy createdBy = &idCopy } results := make([]DocumentUploadResult, 0, len(req.Files)) createdDocs := make([]entity.Document, 0, len(req.Files)) for _, file := range req.Files { if file.File == nil { return nil, errors.New("file header is required") } originalName := sanitizeDocumentName(file.File.Filename) contentType := detectContentType(file.File, originalName) ext := detectExtension(file.File.Filename, contentType) key, err := s.generateObjectKey(ext) if err != nil { s.rollbackDocuments(ctx, createdDocs) return nil, err } reader, err := file.File.Open() if err != nil { s.rollbackDocuments(ctx, createdDocs) return nil, err } uploadRes, err := s.storage.Upload(ctx, key, reader, file.File.Size, contentType) _ = reader.Close() if err != nil { s.rollbackDocuments(ctx, createdDocs) return nil, err } docType := resolveDocumentType(file.Type, documentableType) doc := entity.Document{ DocumentableType: documentableType, DocumentableId: req.DocumentableID, Type: docType, Path: uploadRes.Key, Name: originalName, Ext: strings.TrimPrefix(ext, "."), Size: float64(file.File.Size), CreatedBy: createdBy, } if err := s.repo.CreateOne(ctx, &doc, nil); err != nil { _ = s.storage.Delete(ctx, uploadRes.Key) s.rollbackDocuments(ctx, createdDocs) return nil, err } createdDocs = append(createdDocs, doc) results = append(results, DocumentUploadResult{ Document: doc, URL: uploadRes.URL, Index: cloneIndex(file.Index), }) } return results, nil } func (s *documentService) ListByTarget(ctx context.Context, documentableType string, documentableID uint64) ([]entity.Document, error) { if s.repo == nil { return nil, errors.New("document repository not configured") } documentableType = strings.ToUpper(strings.TrimSpace(documentableType)) if documentableType == "" { return nil, errors.New("documentable type is required") } if documentableID == 0 { return nil, errors.New("documentable id is required") } return s.repo.ListByTarget(ctx, documentableType, documentableID, nil) } func (s *documentService) DeleteDocuments(ctx context.Context, ids []uint, removeFromStorage bool) error { if s.repo == nil { return errors.New("document repository not configured") } if len(ids) == 0 { return nil } docs, err := s.repo.GetByIDs(ctx, ids, nil) if err != nil { return err } for _, doc := range docs { if err := s.repo.DeleteOne(ctx, doc.Id); err != nil { return err } if removeFromStorage && s.storage != nil { if err := s.storage.Delete(ctx, doc.Path); err != nil { utils.Log.WithError(err).Warnf("failed to delete document object %s", doc.Path) } } } return nil } func (s *documentService) DeleteByTarget(ctx context.Context, documentableType string, documentableID uint64, removeFromStorage bool) error { if s.repo == nil { return errors.New("document repository not configured") } documentableType = strings.ToUpper(strings.TrimSpace(documentableType)) if documentableType == "" || documentableID == 0 { return errors.New("documentable type and id are required") } var docs []entity.Document if removeFromStorage && s.storage != nil { var err error docs, err = s.repo.ListByTarget(ctx, documentableType, documentableID, nil) if err != nil { return err } } if err := s.repo.DeleteByTarget(ctx, documentableType, documentableID, nil); err != nil { return err } if removeFromStorage && len(docs) > 0 { for _, doc := range docs { if err := s.storage.Delete(ctx, doc.Path); err != nil { utils.Log.WithError(err).Warnf("failed to delete document object %s", doc.Path) } } } return nil } func (s *documentService) PublicURL(document entity.Document) string { if s.storage == nil || strings.TrimSpace(document.Path) == "" { return "" } return s.storage.URL(document.Path) } func (s *documentService) generateObjectKey(ext string) (string, error) { normalizedExt := strings.TrimSpace(ext) if normalizedExt != "" && !strings.HasPrefix(normalizedExt, ".") { normalizedExt = "." + normalizedExt } u := uuid.New().String() key := fmt.Sprintf("%s/%s%s", strings.Trim(s.keyPrefix, "/"), u, normalizedExt) if s.keyPrefix == "" { key = fmt.Sprintf("%s%s", u, normalizedExt) } if len(key) > s.maxPathLength { key = fmt.Sprintf("%s%s", u, normalizedExt) } if len(key) > s.maxPathLength { return "", fmt.Errorf("object key exceeds maximum length (%d)", s.maxPathLength) } return key, nil } func (s *documentService) rollbackDocuments(ctx context.Context, docs []entity.Document) { if len(docs) == 0 { return } for i := len(docs) - 1; i >= 0; i-- { doc := docs[i] if s.repo != nil && doc.Id != 0 { if err := s.repo.DeleteOne(ctx, doc.Id); err != nil { utils.Log.WithError(err).Warnf("failed to rollback document #%d", doc.Id) } } if s.storage != nil && strings.TrimSpace(doc.Path) != "" { if err := s.storage.Delete(ctx, doc.Path); err != nil { utils.Log.WithError(err).Warnf("failed to rollback document object %s", doc.Path) } } } } func sanitizeDocumentName(name string) string { name = filepath.Base(strings.TrimSpace(name)) if name == "." || name == "" { name = "document" } name = strings.Map(func(r rune) rune { if r < 32 { return -1 } switch r { case '\\', '/', ':', '*', '?', '"', '<', '>', '|': return '-' default: return r } }, name) if len(name) > maxDocumentNameLength { runes := []rune(name) if len(runes) > maxDocumentNameLength { name = string(runes[:maxDocumentNameLength]) } } return name } func detectExtension(filename, contentType string) string { ext := strings.ToLower(strings.TrimSpace(filepath.Ext(filename))) if ext == "" && contentType != "" { if exts, _ := mime.ExtensionsByType(contentType); len(exts) > 0 { ext = exts[0] } } if ext == "" { return ".bin" } if !strings.HasPrefix(ext, ".") { ext = "." + ext } return ext } func detectContentType(file *multipart.FileHeader, filename string) string { if file == nil { return "application/octet-stream" } contentType := strings.TrimSpace(file.Header.Get("Content-Type")) if contentType != "" { return contentType } if ext := filepath.Ext(filename); ext != "" { if guess := mime.TypeByExtension(ext); guess != "" { return guess } } return "application/octet-stream" } func resolveDocumentType(fileType, fallback string) string { value := strings.ToUpper(strings.TrimSpace(fileType)) if value == "" { return fallback } return value } func cloneIndex(index *int) *int { if index == nil { return nil } value := *index return &value }