package service import ( "context" "crypto/md5" "crypto/rand" "encoding/hex" "encoding/json" "errors" "eslogad-be/config" "eslogad-be/internal/appcontext" "eslogad-be/internal/contract" "eslogad-be/internal/entities" "eslogad-be/internal/processor" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "gorm.io/gorm" ) type OnlyOfficeService interface { ProcessCallback(ctx context.Context, documentKey string, req *contract.OnlyOfficeCallbackRequest) (*contract.OnlyOfficeCallbackResponse, error) GetEditorConfig(ctx context.Context, req *contract.GetEditorConfigRequest) (*contract.GetEditorConfigResponse, error) LockDocument(ctx context.Context, documentID uuid.UUID, userID uuid.UUID) error UnlockDocument(ctx context.Context, documentID uuid.UUID, userID uuid.UUID) error GetDocumentSession(ctx context.Context, documentKey string) (*contract.DocumentSession, error) GetOnlyOfficeConfig(ctx context.Context) (*contract.OnlyOfficeConfigInfo, error) } type OnlyOfficeServiceImpl struct { processor processor.OnlyOfficeProcessor documentBaseURL string callbackBaseURL string serverURL string jwtSecret string config *config.OnlyOffice db *gorm.DB fileStorage FileStorage docBucket string } func NewOnlyOfficeService(processor processor.OnlyOfficeProcessor, cfg *config.OnlyOffice, db *gorm.DB, fileStorage FileStorage) *OnlyOfficeServiceImpl { return &OnlyOfficeServiceImpl{ processor: processor, documentBaseURL: getEnvOrDefault("DOCUMENT_BASE_URL", "https://68878e421f6d.ngrok-free.app/api/v1/files"), callbackBaseURL: getEnvOrDefault("CALLBACK_BASE_URL", "https://b4ed0a70d9d6.ngrok-free.app/api/v1/onlyoffice/callback"), serverURL: cfg.URL, jwtSecret: cfg.Token, config: cfg, db: db, fileStorage: fileStorage, docBucket: "documents", // Use the same bucket as document uploads } } func getEnvOrDefault(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value } return defaultValue } func (s *OnlyOfficeServiceImpl) ProcessCallback(ctx context.Context, documentKey string, req *contract.OnlyOfficeCallbackRequest) (*contract.OnlyOfficeCallbackResponse, error) { // Verify JWT token if provided and secret is configured if req.Token != "" && s.jwtSecret != "" { claims, err := s.verifyJWT(req.Token) if err != nil { // Log the error but continue processing // OnlyOffice may not always send valid tokens fmt.Printf("JWT verification failed: %v\n", err) } else if claims != nil { // Extract data from JWT claims if needed if key, ok := claims["key"].(string); ok && key != documentKey { return &contract.OnlyOfficeCallbackResponse{Error: 1}, fmt.Errorf("document key mismatch in JWT") } } } session, err := s.processor.GetDocumentSessionByKey(ctx, documentKey) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return &contract.OnlyOfficeCallbackResponse{Error: 1}, nil // Document key not found } return &contract.OnlyOfficeCallbackResponse{Error: 3}, err // Internal server error } // Process based on status switch req.Status { case contract.OnlyOfficeStatusEditing: // Document is being edited err = s.handleEditingStatus(ctx, session, req) case contract.OnlyOfficeStatusReady: // Document is ready for saving err = s.handleReadyStatus(ctx, session, req) case contract.OnlyOfficeStatusSaveError: // Document saving error err = s.handleSaveError(ctx, session, req) case contract.OnlyOfficeStatusClosed: // Document closed with no changes err = s.handleClosedStatus(ctx, session, req) case contract.OnlyOfficeStatusForceSave: // Force save during editing err = s.handleForceSave(ctx, session, req) case contract.OnlyOfficeStatusForceSaveError: // Force save error err = s.handleForceSaveError(ctx, session, req) default: return &contract.OnlyOfficeCallbackResponse{Error: 3}, fmt.Errorf("unknown status: %d", req.Status) } if err != nil { return &contract.OnlyOfficeCallbackResponse{Error: 3}, err } return &contract.OnlyOfficeCallbackResponse{Error: 0}, nil } // handleEditingStatus handles when document is being edited func (s *OnlyOfficeServiceImpl) handleEditingStatus(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error { // Update session status session.Status = req.Status // Lock document if not already locked if !session.IsLocked && len(req.Users) > 0 { userID := getOnlyOfficeUserIDFromContext(ctx) session.IsLocked = true session.LockedBy = &userID now := time.Now() session.LockedAt = &now } return s.processor.UpdateDocumentSession(ctx, session) } // handleReadyStatus handles when document is ready for saving func (s *OnlyOfficeServiceImpl) handleReadyStatus(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error { if req.URL == "" { return errors.New("document URL is required for saving") } // Download the document documentData, err := s.downloadDocument(req.URL) if err != nil { return fmt.Errorf("failed to download document: %w", err) } // Use session UserID as SavedBy since callbacks don't have user context savedBy := session.UserID if savedBy == uuid.Nil { // Fallback to getting from context if available if userID := getOnlyOfficeUserIDFromContext(ctx); userID != uuid.Nil { savedBy = userID } } // Save new version version := &entities.DocumentVersion{ DocumentID: session.DocumentID, Version: session.Version + 1, FileSize: int64(len(documentData)), SavedBy: savedBy, SavedAt: time.Now(), IsActive: true, } // For now, default to outgoing_attachment // In production, this should be stored in the session or document metadata documentType := "outgoing_attachment" // Generate new file path and save fileName := fmt.Sprintf("v%d_%s_%s.docx", version.Version, time.Now().Format("20060102150405"), session.DocumentKey) filePath, err := s.saveDocumentFile(ctx, documentData, session.DocumentID, fileName, documentType) if err != nil { return fmt.Errorf("failed to save document file: %w", err) } version.FileURL = filePath // Save changes URL if provided if req.ChangesURL != "" { version.ChangesURL = &req.ChangesURL } // Create new version err = s.processor.CreateDocumentVersion(ctx, version) if err != nil { return fmt.Errorf("failed to create document version: %w", err) } // Update session session.Status = req.Status session.Version = version.Version now := time.Now() session.LastSavedAt = &now session.IsLocked = false session.LockedBy = nil session.LockedAt = nil // Update the original document reference with new URL err = s.processor.UpdateDocumentURL(ctx, session.DocumentID, version.FileURL) if err != nil { return fmt.Errorf("failed to update document URL: %w", err) } return s.processor.UpdateDocumentSession(ctx, session) } // handleSaveError handles document save errors func (s *OnlyOfficeServiceImpl) handleSaveError(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error { // Log the error s.processor.LogDocumentError(ctx, session.DocumentID, "Save error occurred", req) // Update session status session.Status = req.Status return s.processor.UpdateDocumentSession(ctx, session) } // handleClosedStatus handles when document is closed without changes func (s *OnlyOfficeServiceImpl) handleClosedStatus(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error { // Unlock document session.Status = req.Status session.IsLocked = false session.LockedBy = nil session.LockedAt = nil return s.processor.UpdateDocumentSession(ctx, session) } func (s *OnlyOfficeServiceImpl) handleForceSave(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error { if req.URL == "" { return errors.New("document URL is required for force save") } documentData, err := s.downloadDocument(req.URL) if err != nil { return fmt.Errorf("failed to download document: %w", err) } savedBy := session.UserID if savedBy == uuid.Nil { if userID := getOnlyOfficeUserIDFromContext(ctx); userID != uuid.Nil { savedBy = userID } } version := &entities.DocumentVersion{ DocumentID: session.DocumentID, Version: session.Version + 1, FileSize: int64(len(documentData)), SavedBy: savedBy, SavedAt: time.Now(), IsActive: false, Comments: stringPtr("Auto-save during editing"), } // For now, default to outgoing_attachment // In production, this should be stored in the session or document metadata documentType := "outgoing_attachment" fileName := fmt.Sprintf("autosave_v%d_%s_%s.docx", version.Version, time.Now().Format("20060102150405"), session.DocumentKey) filePath, err := s.saveDocumentFile(ctx, documentData, session.DocumentID, fileName, documentType) if err != nil { return fmt.Errorf("failed to save document file: %w", err) } version.FileURL = filePath err = s.processor.CreateDocumentVersion(ctx, version) if err != nil { return fmt.Errorf("failed to create document version: %w", err) } now := time.Now() session.LastSavedAt = &now session.Version = version.Version return s.processor.UpdateDocumentSession(ctx, session) } // handleForceSaveError handles force save errors func (s *OnlyOfficeServiceImpl) handleForceSaveError(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error { // Log the error s.processor.LogDocumentError(ctx, session.DocumentID, "Force save error occurred", req) // Update session status session.Status = req.Status return s.processor.UpdateDocumentSession(ctx, session) } // downloadDocument downloads document from OnlyOffice func (s *OnlyOfficeServiceImpl) downloadDocument(url string) ([]byte, error) { resp, err := http.Get(url) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to download document: status %d", resp.StatusCode) } return io.ReadAll(resp.Body) } // saveDocumentFile saves document file to S3 storage func (s *OnlyOfficeServiceImpl) saveDocumentFile(ctx context.Context, data []byte, documentID uuid.UUID, fileName string, documentType string) (string, error) { // Ensure bucket exists if err := s.fileStorage.EnsureBucket(ctx, s.docBucket); err != nil { return "", fmt.Errorf("failed to ensure bucket: %w", err) } // Create S3 key with date structure dateDir := time.Now().Format("2006/01/02") key := fmt.Sprintf("onlyoffice/%s/%s/%s", documentID.String(), dateDir, fileName) // Detect content type from file extension contentType := "application/octet-stream" ext := filepath.Ext(fileName) switch strings.ToLower(ext) { case ".docx": contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" case ".doc": contentType = "application/msword" case ".xlsx": contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" case ".xls": contentType = "application/vnd.ms-excel" case ".pptx": contentType = "application/vnd.openxmlformats-officedocument.presentationml.presentation" case ".ppt": contentType = "application/vnd.ms-powerpoint" case ".pdf": contentType = "application/pdf" } // Upload to S3 url, err := s.fileStorage.Upload(ctx, s.docBucket, key, data, contentType) if err != nil { return "", fmt.Errorf("failed to upload to S3: %w", err) } // Now update the attachment URL in the database if err := s.updateAttachmentURL(ctx, documentID, url, documentType); err != nil { // Log error but don't fail - the document is already saved fmt.Printf("Warning: Failed to update attachment URL: %v\n", err) } return url, nil } // updateAttachmentURL updates the file_url in the appropriate attachment table func (s *OnlyOfficeServiceImpl) updateAttachmentURL(ctx context.Context, attachmentID uuid.UUID, newURL string, documentType string) error { switch documentType { case "letter_outgoing_attachment", "outgoing_attachment": return s.db.WithContext(ctx). Table("letter_outgoing_attachments"). Where("id = ?", attachmentID). Update("file_url", newURL).Error case "letter_incoming_attachment", "incoming_attachment": return s.db.WithContext(ctx). Table("letter_incoming_attachments"). Where("id = ?", attachmentID). Update("file_url", newURL).Error default: return fmt.Errorf("unsupported document type for URL update: %s", documentType) } } // getDocumentFromAttachment retrieves document details directly from attachment tables func (s *OnlyOfficeServiceImpl) getDocumentFromAttachment(ctx context.Context, documentID uuid.UUID, documentType string) (*processor.DocumentDetails, error) { var fileName, fileURL, fileType string var fileSize int64 switch documentType { case "letter_outgoing_attachment", "outgoing_attachment": var attachment struct { FileName string `gorm:"column:file_name"` FileURL string `gorm:"column:file_url"` FileType string `gorm:"column:file_type"` } err := s.db.WithContext(ctx). Table("letter_outgoing_attachments"). Where("id = ?", documentID). Select("file_name, file_url, file_type"). First(&attachment).Error if err != nil { return nil, fmt.Errorf("failed to get outgoing attachment: %w", err) } fileName = attachment.FileName fileURL = attachment.FileURL fileType = attachment.FileType case "letter_incoming_attachment", "incoming_attachment": var attachment struct { FileName string `gorm:"column:file_name"` FileURL string `gorm:"column:file_url"` FileType string `gorm:"column:file_type"` } err := s.db.WithContext(ctx). Table("letter_incoming_attachments"). Where("id = ?", documentID). Select("file_name, file_url, file_type"). First(&attachment).Error if err != nil { return nil, fmt.Errorf("failed to get incoming attachment: %w", err) } fileName = attachment.FileName fileURL = attachment.FileURL fileType = attachment.FileType default: return nil, fmt.Errorf("unsupported document type: %s", documentType) } return &processor.DocumentDetails{ DocumentID: documentID, FileName: fileName, FileType: fileType, FileURL: fileURL, FileSize: fileSize, DocumentType: documentType, ReferenceID: documentID, }, nil } // GetEditorConfig generates OnlyOffice editor configuration func (s *OnlyOfficeServiceImpl) GetEditorConfig(ctx context.Context, req *contract.GetEditorConfigRequest) (*contract.GetEditorConfigResponse, error) { userCtx := appcontext.FromGinContext(ctx) if userCtx == nil { return nil, errors.New("user context not found") } session, err := s.processor.GetOrCreateDocumentSession(ctx, req.DocumentID, userCtx.UserID) if err != nil { return nil, fmt.Errorf("failed to get document session: %w", err) } // Get document details directly from attachment tables document, err := s.getDocumentFromAttachment(ctx, req.DocumentID, req.DocumentType) if err != nil { return nil, fmt.Errorf("failed to get document details: %w", err) } documentKey := session.DocumentKey fileExt := s.getFileExtension(document.FileName) if fileExt == "" || fileExt == strings.ToLower(document.FileName) { fileExt = s.getFileExtension(document.FileType) } ooType := "desktop" if req.DocumentType == "incoming_attachment" { ooType = "embedded" } config := &contract.OnlyOfficeConfigRequest{ Document: &contract.OnlyOfficeDocument{ FileType: fileExt, Key: documentKey, Title: document.FileName, URL: document.FileURL, Permissions: &contract.OnlyOfficePermissions{ Comment: true, Download: true, Edit: req.Mode == "edit", FillForms: true, Print: true, Review: req.Mode == "edit", }, Info: &contract.OnlyOfficeDocumentInfo{ Owner: fmt.Sprintf("User-%s", userCtx.UserID.String()[:8]), Uploaded: time.Now().Format("2006-01-02 15:04:05"), }, }, DocumentType: s.getDocumentType(fileExt), // Convert file extension to document type EditorConfig: &contract.OnlyOfficeEditorConfig{ CallbackURL: fmt.Sprintf("%s/%s", s.callbackBaseURL, documentKey), Lang: "en", Mode: req.Mode, User: &contract.OnlyOfficeUserConfig{ ID: userCtx.UserID.String(), Name: userCtx.UserName, }, Customization: &contract.OnlyOfficeCustomization{ Autosave: true, Comments: true, CompactHeader: false, ForceSave: true, Zoom: 100, }, }, Type: ooType, // Can be desktop, mobile, or embedded } if s.jwtSecret != "" { token, err := s.generateJWT(config) if err != nil { return nil, fmt.Errorf("failed to generate JWT: %w", err) } config.Token = token } return &contract.GetEditorConfigResponse{ DocumentServerURL: s.serverURL, Config: config, }, nil } // LockDocument locks a document for editing func (s *OnlyOfficeServiceImpl) LockDocument(ctx context.Context, documentID uuid.UUID, userID uuid.UUID) error { return s.processor.LockDocument(ctx, documentID, userID) } // UnlockDocument unlocks a document func (s *OnlyOfficeServiceImpl) UnlockDocument(ctx context.Context, documentID uuid.UUID, userID uuid.UUID) error { return s.processor.UnlockDocument(ctx, documentID, userID) } // GetDocumentSession gets document session by key func (s *OnlyOfficeServiceImpl) GetDocumentSession(ctx context.Context, documentKey string) (*contract.DocumentSession, error) { session, err := s.processor.GetDocumentSessionByKey(ctx, documentKey) if err != nil { return nil, err } return &contract.DocumentSession{ ID: session.ID, DocumentID: session.DocumentID, DocumentKey: session.DocumentKey, UserID: session.UserID, Status: session.Status, IsLocked: session.IsLocked, LockedBy: session.LockedBy, LockedAt: session.LockedAt, LastSavedAt: session.LastSavedAt, Version: session.Version, CreatedAt: session.CreatedAt, UpdatedAt: session.UpdatedAt, }, nil } // generateDocumentKey generates a unique key for OnlyOffice func (s *OnlyOfficeServiceImpl) generateDocumentKey(documentID uuid.UUID, version int) string { // Use nanoseconds and random bytes for uniqueness randomBytes := make([]byte, 8) rand.Read(randomBytes) data := fmt.Sprintf("%s_%d_%d_%s", documentID.String(), version, time.Now().UnixNano(), hex.EncodeToString(randomBytes)) hash := md5.Sum([]byte(data)) return hex.EncodeToString(hash[:]) } // getFileExtension extracts the file extension from file type or filename func (s *OnlyOfficeServiceImpl) getFileExtension(fileType string) string { // Remove any leading dot fileType = strings.TrimPrefix(fileType, ".") // If fileType contains a dot, extract the extension after the last dot if strings.Contains(fileType, ".") { parts := strings.Split(fileType, ".") if len(parts) > 1 { return strings.ToLower(parts[len(parts)-1]) } } // Otherwise, return as is (assuming it's already an extension like "docx", "xlsx", etc.) return strings.ToLower(fileType) } // getDocumentType determines OnlyOffice document type from file extension func (s *OnlyOfficeServiceImpl) getDocumentType(fileType string) string { fileType = strings.ToLower(fileType) // Remove dot if present fileType = strings.TrimPrefix(fileType, ".") // Text documents if fileType == "doc" || fileType == "docx" || fileType == "docm" || fileType == "dot" || fileType == "dotx" || fileType == "dotm" || fileType == "odt" || fileType == "fodt" || fileType == "ott" || fileType == "rtf" || fileType == "txt" || fileType == "html" || fileType == "htm" || fileType == "mht" || fileType == "pdf" || fileType == "djvu" || fileType == "fb2" || fileType == "epub" || fileType == "xps" { return "word" } // Spreadsheets if fileType == "xls" || fileType == "xlsx" || fileType == "xlsm" || fileType == "xlt" || fileType == "xltx" || fileType == "xltm" || fileType == "ods" || fileType == "fods" || fileType == "ots" || fileType == "csv" { return "cell" } // Presentations if fileType == "pps" || fileType == "ppsx" || fileType == "ppsm" || fileType == "ppt" || fileType == "pptx" || fileType == "pptm" || fileType == "pot" || fileType == "potx" || fileType == "potm" || fileType == "odp" || fileType == "fodp" || fileType == "otp" { return "presentation" } // Default to text return "slide" } // generateJWT generates JWT token for OnlyOffice func (s *OnlyOfficeServiceImpl) generateJWT(config *contract.OnlyOfficeConfigRequest) (string, error) { // OnlyOffice expects the entire config to be in the JWT payload payload := make(map[string]interface{}) // Convert the config struct to a map for JWT payload configJSON, err := json.Marshal(config) if err != nil { return "", fmt.Errorf("failed to marshal config: %w", err) } var configMap map[string]interface{} if err := json.Unmarshal(configJSON, &configMap); err != nil { return "", fmt.Errorf("failed to unmarshal config to map: %w", err) } // Add all config fields to the payload for key, value := range configMap { payload[key] = value } // Create the token with the payload token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(payload)) // Sign the token with the secret tokenString, err := token.SignedString([]byte(s.jwtSecret)) if err != nil { return "", fmt.Errorf("failed to sign JWT token: %w", err) } return tokenString, nil } func getOnlyOfficeUserIDFromContext(ctx context.Context) uuid.UUID { userCtx := appcontext.FromGinContext(ctx) if userCtx != nil { return userCtx.UserID } return uuid.Nil } func stringPtr(s string) *string { return &s } // GetOnlyOfficeConfig returns the OnlyOffice configuration func (s *OnlyOfficeServiceImpl) GetOnlyOfficeConfig(ctx context.Context) (*contract.OnlyOfficeConfigInfo, error) { return &contract.OnlyOfficeConfigInfo{ URL: s.config.URL, Token: s.config.Token, }, nil } // verifyJWT verifies JWT token from OnlyOffice func (s *OnlyOfficeServiceImpl) verifyJWT(tokenString string) (jwt.MapClaims, error) { if s.jwtSecret == "" { // If no secret is configured, skip verification return nil, nil } token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { // Validate the signing method if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return []byte(s.jwtSecret), nil }) if err != nil { return nil, fmt.Errorf("failed to parse JWT: %w", err) } if !token.Valid { return nil, errors.New("invalid JWT token") } claims, ok := token.Claims.(jwt.MapClaims) if !ok { return nil, errors.New("failed to parse JWT claims") } return claims, nil }