dukcapil/internal/service/onlyoffice_service.go
2025-10-17 08:56:26 +07:00

709 lines
22 KiB
Go

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://eslogad-api.apskel.org/api/v1/files"),
callbackBaseURL: getEnvOrDefault("CALLBACK_BASE_URL", "https://eslogad-api.apskel.org/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
}