package service import ( "context" "fmt" "github.com/gofiber/fiber/v2" "github.com/google/uuid" "github.com/minio/minio-go/v7" "github.com/rs/zerolog" "io" "log" "math/rand" "mime" "mime/multipart" "netidhub-saas-be/app/module/article_files/mapper" "netidhub-saas-be/app/module/article_files/repository" "netidhub-saas-be/app/module/article_files/request" "netidhub-saas-be/app/module/article_files/response" usersRepository "netidhub-saas-be/app/module/users/repository" config "netidhub-saas-be/config/config" minioStorage "netidhub-saas-be/config/config" "netidhub-saas-be/utils/paginator" utilSvc "netidhub-saas-be/utils/service" "os" "path/filepath" "strconv" "strings" "sync" "time" ) // ArticleFilesService type articleFilesService struct { Repo repository.ArticleFilesRepository UsersRepo usersRepository.UsersRepository Log zerolog.Logger Cfg *config.Config MinioStorage *minioStorage.MinioStorage } // ArticleFilesService define interface of IArticleFilesService type ArticleFilesService interface { All(authToken string, req request.ArticleFilesQueryRequest) (articleFiles []*response.ArticleFilesResponse, paging paginator.Pagination, err error) Show(authToken string, id uint) (articleFiles *response.ArticleFilesResponse, err error) Save(authToken string, c *fiber.Ctx, id uint) error SaveAsync(authToken string, c *fiber.Ctx, id uint) error Update(authToken string, id uint, req request.ArticleFilesUpdateRequest) (err error) GetUploadStatus(c *fiber.Ctx) (progress int, err error) Delete(authToken string, id uint) error Viewer(authToken string, c *fiber.Ctx) error } // NewArticleFilesService init ArticleFilesService func NewArticleFilesService(repo repository.ArticleFilesRepository, log zerolog.Logger, cfg *config.Config, minioStorage *minioStorage.MinioStorage, usersRepo usersRepository.UsersRepository) ArticleFilesService { return &articleFilesService{ Repo: repo, UsersRepo: usersRepo, Log: log, Cfg: cfg, MinioStorage: minioStorage, } } var ( progressMap = make(map[string]int) // Menyimpan progress upload per UploadID progressLock = sync.Mutex{} ) type progressWriter struct { uploadID string totalSize int64 uploadedSize *int64 } // All implement interface of ArticleFilesService func (_i *articleFilesService) All(authToken string, req request.ArticleFilesQueryRequest) (articleFiless []*response.ArticleFilesResponse, paging paginator.Pagination, err error) { // Extract clientId from authToken var clientId *uuid.UUID if authToken != "" { user := utilSvc.GetUserInfo(_i.Log, _i.UsersRepo, authToken) if user != nil && user.ClientId != nil { clientId = user.ClientId _i.Log.Info().Interface("clientId", clientId).Msg("Extracted clientId from auth token") } } results, paging, err := _i.Repo.GetAll(clientId, req) if err != nil { return } host := _i.Cfg.App.Domain for _, result := range results { articleFiless = append(articleFiless, mapper.ArticleFilesResponseMapper(result, host)) } return } func (_i *articleFilesService) Show(authToken string, id uint) (articleFiles *response.ArticleFilesResponse, err error) { // Extract clientId from authToken var clientId *uuid.UUID if authToken != "" { user := utilSvc.GetUserInfo(_i.Log, _i.UsersRepo, authToken) if user != nil && user.ClientId != nil { clientId = user.ClientId _i.Log.Info().Interface("clientId", clientId).Msg("Extracted clientId from auth token") } } result, err := _i.Repo.FindOne(clientId, id) if err != nil { return nil, err } host := _i.Cfg.App.Domain return mapper.ArticleFilesResponseMapper(result, host), nil } func (_i *articleFilesService) SaveAsync(authToken string, c *fiber.Ctx, id uint) (err error) { // Extract clientId from authToken var clientId *uuid.UUID if authToken != "" { user := utilSvc.GetUserInfo(_i.Log, _i.UsersRepo, authToken) if user != nil && user.ClientId != nil { clientId = user.ClientId _i.Log.Info().Interface("clientId", clientId).Msg("Extracted clientId from auth token") } } bucketName := _i.MinioStorage.Cfg.ObjectStorage.MinioStorage.BucketName ctx := context.Background() form, err := c.MultipartForm() if err != nil { return err } //filess := form.File["files"] // Create minio connection. minioClient, err := _i.MinioStorage.ConnectMinio() if err != nil { // Return status 500 and minio connection error. return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "error": true, "msg": err.Error(), }) } for _, files := range form.File { _i.Log.Info().Str("timestamp", time.Now(). Format(time.RFC3339)).Str("Service:Resource", "Uploader:: top"). Interface("files", files).Msg("") for _, fileHeader := range files { _i.Log.Info().Str("timestamp", time.Now(). Format(time.RFC3339)).Str("Service:Resource", "Uploader:: loop"). Interface("data", fileHeader).Msg("") filename := filepath.Base(fileHeader.Filename) filenameAlt := filepath.Clean(filename[:len(filename)-len(filepath.Ext(filename))]) filename = strings.ReplaceAll(filename, " ", "") filenameWithoutExt := filepath.Clean(filename[:len(filename)-len(filepath.Ext(filename))]) extension := filepath.Ext(fileHeader.Filename)[1:] now := time.Now() rand.New(rand.NewSource(now.UnixNano())) randUniqueId := rand.Intn(1000000) uploadID := strconv.Itoa(randUniqueId) newFilenameWithoutExt := filenameWithoutExt + "_" + strconv.Itoa(randUniqueId) newFilename := newFilenameWithoutExt + "." + extension objectName := fmt.Sprintf("articles/upload/%d/%d/%s", now.Year(), now.Month(), newFilename) fileSize := strconv.FormatInt(fileHeader.Size, 10) fileSizeInt := fileHeader.Size _i.Log.Info().Str("timestamp", time.Now(). Format(time.RFC3339)).Str("Service:Resource", "Uploader:: top"). Interface("Start upload", uploadID).Msg("") req := request.ArticleFilesCreateRequest{ ArticleId: id, UploadId: &uploadID, FilePath: &objectName, FileName: &newFilename, FileAlt: &filenameAlt, Size: &fileSize, } err = _i.Repo.Create(clientId, req.ToEntity()) if err != nil { return err } src, err := fileHeader.Open() if err != nil { return err } defer src.Close() tempFilePath := fmt.Sprintf("/tmp/%s", newFilename) tempFile, err := os.Create(tempFilePath) if err != nil { return err } defer tempFile.Close() // Copy file ke direktori sementara _, err = io.Copy(tempFile, src) if err != nil { return err } go uploadToMinIO(ctx, _i.Log, minioClient, uploadID, tempFilePath, bucketName, objectName, fileSizeInt) } } _i.Log.Info().Str("timestamp", time.Now(). Format(time.RFC3339)).Str("Service:Resource", "User:All"). Interface("data", "Successfully uploaded").Msg("") return } func (_i *articleFilesService) Save(authToken string, c *fiber.Ctx, id uint) (err error) { // Extract clientId from authToken var clientId *uuid.UUID if authToken != "" { user := utilSvc.GetUserInfo(_i.Log, _i.UsersRepo, authToken) if user != nil && user.ClientId != nil { clientId = user.ClientId _i.Log.Info().Interface("clientId", clientId).Msg("Extracted clientId from auth token") } } bucketName := _i.MinioStorage.Cfg.ObjectStorage.MinioStorage.BucketName form, err := c.MultipartForm() if err != nil { return err } //filess := form.File["files"] // Create minio connection. minioClient, err := _i.MinioStorage.ConnectMinio() if err != nil { // Return status 500 and minio connection error. return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "error": true, "msg": err.Error(), }) } for _, files := range form.File { _i.Log.Info().Str("timestamp", time.Now(). Format(time.RFC3339)).Str("Service:Resource", "Uploader:: top"). Interface("files", files).Msg("") for _, fileHeader := range files { _i.Log.Info().Str("timestamp", time.Now(). Format(time.RFC3339)).Str("Service:Resource", "Uploader:: loop"). Interface("data", fileHeader).Msg("") src, err := fileHeader.Open() if err != nil { return err } defer src.Close() filename := filepath.Base(fileHeader.Filename) filenameAlt := filepath.Clean(filename[:len(filename)-len(filepath.Ext(filename))]) filename = strings.ReplaceAll(filename, " ", "") filenameWithoutExt := filepath.Clean(filename[:len(filename)-len(filepath.Ext(filename))]) extension := filepath.Ext(fileHeader.Filename)[1:] now := time.Now() rand.New(rand.NewSource(now.UnixNano())) randUniqueId := rand.Intn(1000000) newFilenameWithoutExt := filenameWithoutExt + "_" + strconv.Itoa(randUniqueId) newFilename := newFilenameWithoutExt + "." + extension objectName := fmt.Sprintf("articles/upload/%d/%d/%s", now.Year(), now.Month(), newFilename) fileSize := strconv.FormatInt(fileHeader.Size, 10) req := request.ArticleFilesCreateRequest{ ArticleId: id, FilePath: &objectName, FileName: &newFilename, FileAlt: &filenameAlt, Size: &fileSize, } err = _i.Repo.Create(clientId, req.ToEntity()) if err != nil { return err } // Upload file ke MinIO _, err = minioClient.PutObject(context.Background(), bucketName, objectName, src, fileHeader.Size, minio.PutObjectOptions{}) if err != nil { return err } } } _i.Log.Info().Str("timestamp", time.Now(). Format(time.RFC3339)).Str("Service:Resource", "User:All"). Interface("data", "Successfully uploaded").Msg("") return } func (_i *articleFilesService) Update(authToken string, id uint, req request.ArticleFilesUpdateRequest) (err error) { // Extract clientId from authToken var clientId *uuid.UUID if authToken != "" { user := utilSvc.GetUserInfo(_i.Log, _i.UsersRepo, authToken) if user != nil && user.ClientId != nil { clientId = user.ClientId _i.Log.Info().Interface("clientId", clientId).Msg("Extracted clientId from auth token") } } _i.Log.Info().Interface("data", req).Msg("") return _i.Repo.Update(clientId, id, req.ToEntity()) } func (_i *articleFilesService) Delete(authToken string, id uint) error { // Extract clientId from authToken var clientId *uuid.UUID if authToken != "" { user := utilSvc.GetUserInfo(_i.Log, _i.UsersRepo, authToken) if user != nil && user.ClientId != nil { clientId = user.ClientId _i.Log.Info().Interface("clientId", clientId).Msg("Extracted clientId from auth token") } } result, err := _i.Repo.FindOne(clientId, id) if err != nil { return err } result.IsActive = false return _i.Repo.Update(clientId, id, result) } func (_i *articleFilesService) Viewer(authToken string, c *fiber.Ctx) (err error) { // Extract clientId from authToken var clientId *uuid.UUID if authToken != "" { user := utilSvc.GetUserInfo(_i.Log, _i.UsersRepo, authToken) if user != nil && user.ClientId != nil { clientId = user.ClientId _i.Log.Info().Interface("clientId", clientId).Msg("Extracted clientId from auth token") } } filename := c.Params("filename") result, err := _i.Repo.FindByFilename(clientId, filename) if err != nil { return err } ctx := context.Background() bucketName := _i.MinioStorage.Cfg.ObjectStorage.MinioStorage.BucketName objectName := *result.FilePath _i.Log.Info().Str("timestamp", time.Now(). Format(time.RFC3339)).Str("Service:Resource", "Article:Uploads"). Interface("data", objectName).Msg("") // Create minio connection. minioClient, err := _i.MinioStorage.ConnectMinio() if err != nil { // Return status 500 and minio connection error. return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ "error": true, "msg": err.Error(), }) } fileContent, err := minioClient.GetObject(ctx, bucketName, objectName, minio.GetObjectOptions{}) if err != nil { log.Fatalln(err) } defer fileContent.Close() // Tentukan Content-Type berdasarkan ekstensi file contentType := mime.TypeByExtension("." + getFileExtension(objectName)) if contentType == "" { contentType = "application/octet-stream" // fallback jika tidak ada tipe MIME yang cocok } c.Set("Content-Type", contentType) if _, err := io.Copy(c.Response().BodyWriter(), fileContent); err != nil { return err } return } func getFileExtension(filename string) string { // split file name parts := strings.Split(filename, ".") // jika tidak ada ekstensi, kembalikan string kosong if len(parts) == 1 || (len(parts) == 2 && parts[0] == "") { return "" } // ambil ekstensi terakhir return parts[len(parts)-1] } func uploadTempFile(log zerolog.Logger, fileHeader *multipart.FileHeader, filePath string) { src, err := fileHeader.Open() if err != nil { log.Info().Str("timestamp", time.Now(). Format(time.RFC3339)).Str("Service:Resource", "Article:uploadToMinIO-0"). Interface("err", err).Msg("") } defer src.Close() tempFile, err := os.Create(filePath) if err != nil { log.Info().Str("timestamp", time.Now(). Format(time.RFC3339)).Str("Service:Resource", "Article:uploadToMinIO-1"). Interface("err", err).Msg("") } defer tempFile.Close() // Copy file ke direktori sementara _, err = io.Copy(tempFile, src) if err != nil { log.Info().Str("timestamp", time.Now(). Format(time.RFC3339)).Str("Service:Resource", "Article:uploadToMinIO-2"). Interface("err", err).Msg("") } } func uploadToMinIO(ctx context.Context, log zerolog.Logger, minioClient *minio.Client, uploadID, filePath, bucketName string, objectName string, fileSize int64) { file, err := os.Open(filePath) if err != nil { log.Info().Str("timestamp", time.Now(). Format(time.RFC3339)).Str("Service:Resource", "Article:uploadToMinIO-3"). Interface("err", err).Msg("") return } defer file.Close() // Upload file ke MinIO dengan progress tracking uploadProgress := int64(0) reader := io.TeeReader(file, &progressWriter{uploadID: uploadID, totalSize: fileSize, uploadedSize: &uploadProgress}) _, err = minioClient.PutObject(ctx, bucketName, objectName, reader, fileSize, minio.PutObjectOptions{}) if err != nil { log.Info().Str("timestamp", time.Now(). Format(time.RFC3339)).Str("Service:Resource", "Article:uploadToMinIO-4"). Interface("err", err).Msg("") return } // Upload selesai, update progress menjadi 100 progressLock.Lock() progressMap[uploadID] = 100 progressLock.Unlock() go removeFileTemp(log, filePath) } func removeFileTemp(log zerolog.Logger, filePath string) { err := os.Remove(filePath) if err != nil { log.Info().Str("timestamp", time.Now(). Format(time.RFC3339)).Str("Service:Resource", "Article:uploadToMinIO-5"). Interface("Failed to remove temporary file", err).Msg("") } else { log.Info().Str("timestamp", time.Now(). Format(time.RFC3339)).Str("Service:Resource", "Article:uploadToMinIO-6"). Interface("err", "Temporary file removed").Msg("") } } func (p *progressWriter) Write(data []byte) (int, error) { n := len(data) progressLock.Lock() defer progressLock.Unlock() *p.uploadedSize += int64(n) progress := int(float64(*p.uploadedSize) / float64(p.totalSize) * 100) // Update progress di map progressMap[p.uploadID] = progress return n, nil } func (_i *articleFilesService) GetUploadStatus(c *fiber.Ctx) (progress int, err error) { uploadID := c.Params("uploadId") // Ambil progress dari map progressLock.Lock() progress, _ = progressMap[uploadID] progressLock.Unlock() return progress, nil }