mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
481 lines
12 KiB
Go
481 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
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 = 255
|
|
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
|
|
PresignURL(ctx context.Context, document entity.Document, expires time.Duration) (string, error)
|
|
}
|
|
|
|
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) PresignURL(ctx context.Context, document entity.Document, expires time.Duration) (string, error) {
|
|
if s.storage == nil {
|
|
return "", errors.New("document storage not configured")
|
|
}
|
|
if strings.TrimSpace(document.Path) == "" {
|
|
return "", errors.New("document path is required")
|
|
}
|
|
return s.storage.PresignURL(ctx, document.Path, expires)
|
|
}
|
|
|
|
// ResolveDocumentURL normalizes a stored path or URL into a presigned URL.
|
|
func ResolveDocumentURL(
|
|
ctx context.Context,
|
|
svc DocumentService,
|
|
rawPath string,
|
|
expires time.Duration,
|
|
) (string, error) {
|
|
if svc == nil {
|
|
return "", nil
|
|
}
|
|
|
|
rawPath = strings.TrimSpace(rawPath)
|
|
if rawPath == "" {
|
|
return "", nil
|
|
}
|
|
|
|
key := rawPath
|
|
lower := strings.ToLower(rawPath)
|
|
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
|
|
key = extractS3KeyFromURL(rawPath)
|
|
if key == "" {
|
|
return "", nil
|
|
}
|
|
}
|
|
|
|
return svc.PresignURL(ctx, entity.Document{Path: key}, expires)
|
|
}
|
|
|
|
func extractS3KeyFromURL(raw string) string {
|
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
path := strings.TrimPrefix(parsed.Path, "/")
|
|
if path == "" {
|
|
return ""
|
|
}
|
|
|
|
host := strings.ToLower(strings.TrimSpace(parsed.Host))
|
|
if strings.HasPrefix(host, "s3.") || strings.HasPrefix(host, "s3-") {
|
|
parts := strings.SplitN(path, "/", 2)
|
|
if len(parts) == 2 {
|
|
return parts[1]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
return path
|
|
}
|
|
|
|
func (s *documentService) generateObjectKey(ext string) (string, error) {
|
|
normalizedExt := strings.TrimSpace(ext)
|
|
if normalizedExt != "" && !strings.HasPrefix(normalizedExt, ".") {
|
|
normalizedExt = "." + normalizedExt
|
|
}
|
|
|
|
u := uuid.New().String()
|
|
keyPrefix := strings.Trim(s.keyPrefix, "/")
|
|
key := fmt.Sprintf("%s%s", u, normalizedExt)
|
|
if keyPrefix != "" {
|
|
key = fmt.Sprintf("%s/%s%s", keyPrefix, u, normalizedExt)
|
|
}
|
|
|
|
if len(key) > s.maxPathLength {
|
|
compact := strings.ReplaceAll(u, "-", "")
|
|
if keyPrefix != "" {
|
|
key = fmt.Sprintf("%s/%s%s", keyPrefix, compact, normalizedExt)
|
|
} else {
|
|
key = fmt.Sprintf("%s%s", compact, 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
|
|
}
|