package onlyoffice import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "path" "strconv" "strings" "time" ) // FileEntry is a file row from the OnlyOffice Files module or project/task // file listings (field names follow the Workspace API JSON). type FileEntry struct { ID *json.Number `json:"id,omitempty"` Title *string `json:"title,omitempty"` FileExst *string `json:"fileExst,omitempty"` ContentLength *string `json:"contentLength,omitempty"` FileType *int `json:"fileType,omitempty"` ViewURL *string `json:"viewUrl,omitempty"` WebURL *string `json:"webUrl,omitempty"` FolderID *json.Number `json:"folderId,omitempty"` Updated *time.Time `json:"updated,omitempty"` CreatedBy *User `json:"createdBy,omitempty"` } // FolderEntry is a folder row from project files listing. type FolderEntry struct { ID *json.Number `json:"id,omitempty"` Title *string `json:"title,omitempty"` FilesCount *int `json:"filesCount,omitempty"` FoldersCount *int `json:"foldersCount,omitempty"` } // ProjectFilesResponse is the "response" object from GET // /api/2.0/project/{id}/files — files and folders attached to the project. type ProjectFilesResponse struct { Folders []*FolderEntry `json:"folders"` Files []*FileEntry `json:"files"` } // UploadOpportunityFile uploads a single file to a CRM opportunity. // Returns the decoded "response" object from OnlyOffice. func (c *Client) UploadOpportunityFile(ctx context.Context, opportunityID, filePath string) (map[string]any, error) { p := fmt.Sprintf("/api/2.0/crm/opportunity/%s/files/upload.json", url.PathEscape(opportunityID)) raw, err := c.uploadMultipart(ctx, p, "file", filePath) if err != nil { return nil, err } return unmarshalResponseObject(raw) } // GetProjectFiles returns files and folders linked to the project. func (c *Client) GetProjectFiles(ctx context.Context, projectID string) (*ProjectFilesResponse, error) { if projectID == "" { projectID = c.defaults.ProjectID } if projectID == "" { return nil, fmt.Errorf("project id is required") } p := fmt.Sprintf("/api/2.0/project/%s/files.json", url.PathEscape(projectID)) raw, err := c.getJSON(ctx, p) if err != nil { return nil, err } resp, err := responseField(raw, "response") if err != nil { return nil, err } if len(resp) == 0 || string(resp) == "null" { return &ProjectFilesResponse{}, nil } var out ProjectFilesResponse if err := json.Unmarshal(resp, &out); err != nil { return nil, err } return &out, nil } // GetTaskFiles returns files attached to a project task. func (c *Client) GetTaskFiles(ctx context.Context, taskID string) ([]*FileEntry, error) { if taskID == "" { return nil, fmt.Errorf("task id is required") } p := fmt.Sprintf("/api/2.0/project/task/%s/files.json", url.PathEscape(taskID)) raw, err := c.getJSON(ctx, p) if err != nil { return nil, err } resp, err := responseField(raw, "response") if err != nil { return nil, err } if len(resp) == 0 || string(resp) == "null" { return nil, nil } var list []*FileEntry if err := json.Unmarshal(resp, &list); err != nil { return nil, err } return list, nil } // UploadTaskFile uploads a file into the task's project Documents folder, then // attaches the new file id to the task. OnlyOffice POST .../task/{id}/files // expects existing file IDs, not a multipart body. func (c *Client) UploadTaskFile(ctx context.Context, taskID, localPath string) (*FileEntry, error) { if taskID == "" { return nil, fmt.Errorf("task id is required") } task, err := c.GetTaskByID(ctx, taskID) if err != nil { return nil, err } pid := projectIDFromTaskMap(task) if pid == "" { return nil, fmt.Errorf("task %s: cannot resolve project id for upload", taskID) } entry, err := c.UploadProjectFile(ctx, pid, localPath) if err != nil { return nil, err } nid := int(FileEntryNumericID(entry)) if nid == 0 { return nil, fmt.Errorf("upload returned no file id") } if err := c.AttachFilesToTask(ctx, taskID, nid); err != nil { return nil, err } return entry, nil } // AttachFilesToTask links existing Documents-module files to a task. func (c *Client) AttachFilesToTask(ctx context.Context, taskID string, fileIDs ...int) error { if taskID == "" || len(fileIDs) == 0 { return fmt.Errorf("task id and at least one file id are required") } v := url.Values{} for _, id := range fileIDs { v.Add("files", strconv.Itoa(id)) } p := fmt.Sprintf("/api/2.0/project/task/%s/files.json", url.PathEscape(taskID)) if _, err := c.postForm(ctx, p, v); err != nil { p2 := fmt.Sprintf("/api/2.0/project/task/%s/files", url.PathEscape(taskID)) if _, err2 := c.postForm(ctx, p2, v); err2 != nil { return fmt.Errorf("attach files to task: %w (retry: %v)", err, err2) } } return nil } func projectIDFromTaskMap(m map[string]any) string { if m == nil { return "" } if po, ok := m["projectOwner"].(map[string]any); ok { if id, ok := po["id"]; ok { switch x := id.(type) { case float64: return strconv.FormatInt(int64(x), 10) case int: return strconv.Itoa(x) case string: return x } } } return "" } // DetachTaskFile removes a file attachment from the task (file remains in Documents). func (c *Client) DetachTaskFile(ctx context.Context, taskID, fileID string) error { if taskID == "" || fileID == "" { return fmt.Errorf("task id and file id are required") } q := url.Values{} q.Set("fileid", fileID) p := fmt.Sprintf("/api/2.0/project/task/%s/files.json?%s", url.PathEscape(taskID), q.Encode()) if _, err := c.deleteReq(ctx, p); err != nil { p2 := fmt.Sprintf("/api/2.0/project/task/%s/files?%s", url.PathEscape(taskID), q.Encode()) if _, err2 := c.deleteReq(ctx, p2); err2 != nil { return fmt.Errorf("detach task file: %w (retry: %v)", err, err2) } } return nil } // projectFolderID resolves the Documents folder id for project file uploads. func (c *Client) projectFolderID(ctx context.Context, projectID string) (string, error) { m, err := c.GetProjectByID(ctx, projectID) if err != nil { return "", err } if v, ok := m["projectFolder"]; ok && v != nil { switch x := v.(type) { case float64: return strconv.FormatInt(int64(x), 10), nil case json.Number: return x.String(), nil case string: if x != "" { return x, nil } } } // Fallback: first folder from project files listing. pf, err := c.GetProjectFiles(ctx, projectID) if err != nil { return "", err } if len(pf.Folders) > 0 && pf.Folders[0].ID != nil { return pf.Folders[0].ID.String(), nil } return "", fmt.Errorf("project %s has no projectFolder and no folders in files listing", projectID) } // UploadProjectFile uploads a file into the project's Documents folder. func (c *Client) UploadProjectFile(ctx context.Context, projectID, localPath string) (*FileEntry, error) { folderID, err := c.projectFolderID(ctx, projectID) if err != nil { return nil, err } // Workspace DocumentsApi.UploadFile: POST .../{folderId}/upload (multipart or raw stream). uploadPath := fmt.Sprintf("/api/2.0/files/%s/upload.json", url.PathEscape(folderID)) raw, err := c.uploadMultipart(ctx, uploadPath, "file", localPath) if err != nil { uploadPath = fmt.Sprintf("/api/2.0/files/%s/upload", url.PathEscape(folderID)) raw, err = c.uploadMultipart(ctx, uploadPath, "file", localPath) if err != nil { return nil, err } } return decodeResponseFileEntry(raw) } // UploadProjectFileReplacing upserts by stem|ext in the project Documents folder. func (c *Client) UploadProjectFileReplacing(ctx context.Context, projectID, localPath string) (*FileEntry, []int, error) { folderID, err := c.projectFolderID(ctx, projectID) if err != nil { return nil, nil, err } return c.UploadToFolderReplacing(ctx, folderID, localPath) } // GetFile returns file metadata including viewUrl for download. func (c *Client) GetFile(ctx context.Context, fileID string) (*FileEntry, error) { if fileID == "" { return nil, fmt.Errorf("file id is required") } p := fmt.Sprintf("/api/2.0/files/file/%s.json", url.PathEscape(fileID)) raw, err := c.getJSON(ctx, p) if err != nil { return nil, err } return decodeResponseFileEntry(raw) } // RenameFile sets a new title (including extension) for the file. func (c *Client) RenameFile(ctx context.Context, fileID, newTitle string) (*FileEntry, error) { if fileID == "" || newTitle == "" { return nil, fmt.Errorf("file id and new title are required") } p := fmt.Sprintf("/api/2.0/files/file/%s.json", url.PathEscape(fileID)) raw, err := c.putJSON(ctx, p, map[string]string{"title": newTitle}) if err != nil { return nil, err } return decodeResponseFileEntry(raw) } // DeleteFiles permanently deletes files by numeric id (Documents module). // Uses per-file DELETE (DeleteDavItems); fileops/delete returns 200 on some // portals (e.g. produktor.io) without removing the file. func (c *Client) DeleteFiles(ctx context.Context, fileIDs []int) error { if len(fileIDs) == 0 { return fmt.Errorf("no file ids to delete") } strIDs := make([]string, len(fileIDs)) for i, id := range fileIDs { strIDs[i] = strconv.Itoa(id) } return c.DeleteDavItems(ctx, nil, strIDs) } // ListFolder returns the Documents module listing for a folder id // (GET /api/2.0/files/{folderId}). func (c *Client) ListFolder(ctx context.Context, folderID string) (map[string]any, error) { if folderID == "" { return nil, fmt.Errorf("folder id is required") } out, err := c.ResponseObject(ctx, "/api/2.0/files/"+url.PathEscape(folderID)+".json") if err != nil { out, err = c.ResponseObject(ctx, "/api/2.0/files/"+url.PathEscape(folderID)) } return out, err } // CreateFolder creates a subfolder under parentFolderID. func (c *Client) CreateFolder(ctx context.Context, parentFolderID, title string) (map[string]any, error) { if parentFolderID == "" || title == "" { return nil, fmt.Errorf("parent folder id and title are required") } body := map[string]any{"title": title} out, err := c.postJSONObject(ctx, "/api/2.0/files/folder/"+url.PathEscape(parentFolderID)+".json", body) if err != nil { out, err = c.postJSONObject(ctx, "/api/2.0/files/folder/"+url.PathEscape(parentFolderID), body) } return out, err } // MoveFiles moves file ids into destFolderID (Documents fileops/move). func (c *Client) MoveFiles(ctx context.Context, destFolderID int, fileIDs []int) (map[string]any, error) { if destFolderID == 0 || len(fileIDs) == 0 { return nil, fmt.Errorf("dest folder and file ids are required") } body := map[string]any{ "folderIds": []int{}, "fileIds": fileIDs, "destFolderId": destFolderID, "resolveType": "Skip", "holdResult": true, } // fileops/move answers an operations envelope (like MoveDavItems), not a // single object, so parse the raw body before unwrapping and surface any // per-operation error. Unwrapping first (putJSONObject) made fileopsError // look for a "response" key that was already stripped. raw, err := c.putJSON(ctx, "/api/2.0/files/fileops/move", body) if err != nil { raw, err = c.putJSON(ctx, "/api/2.0/files/fileops/move.json", body) if err != nil { return nil, err } } if ferr := fileopsError(raw); ferr != nil { return nil, ferr } out, _ := unmarshalResponseObject(raw) return out, nil } // UploadToFolder uploads a local file into an arbitrary Documents folder id. func (c *Client) UploadToFolder(ctx context.Context, folderID, localPath string) (*FileEntry, error) { if folderID == "" || localPath == "" { return nil, fmt.Errorf("folder id and local path are required") } uploadPath := fmt.Sprintf("/api/2.0/files/%s/upload.json", url.PathEscape(folderID)) raw, err := c.uploadMultipart(ctx, uploadPath, "file", localPath) if err != nil { uploadPath = fmt.Sprintf("/api/2.0/files/%s/upload", url.PathEscape(folderID)) raw, err = c.uploadMultipart(ctx, uploadPath, "file", localPath) if err != nil { return nil, err } } return decodeResponseFileEntry(raw) } // UpdateFile uploads a new version of an existing file (same id, name and // folder). It does not delete and does not create a second file. // // The Documents API method is PUT /api/2.0/files/{id}/update; POST is kept as // a fallback for older servers. The path is tried with and without .json. func (c *Client) UpdateFile(ctx context.Context, fileID, localPath string) (*FileEntry, error) { if fileID == "" || localPath == "" { return nil, fmt.Errorf("file id and local path are required") } base := fmt.Sprintf("/api/2.0/files/%s/update", url.PathEscape(fileID)) attempts := []struct { method, path string }{ {http.MethodPut, base}, {http.MethodPut, base + ".json"}, {http.MethodPost, base}, {http.MethodPost, base + ".json"}, } var lastErr error for _, a := range attempts { raw, err := c.uploadMultipartMethod(ctx, a.method, a.path, "file", localPath) if err == nil { return decodeResponseFileEntry(raw) } lastErr = err } return nil, lastErr } // FileFolderID returns the parent folder id string for a file entry, if known. func FileFolderID(f *FileEntry) string { if f == nil || f.FolderID == nil { return "" } return f.FolderID.String() } // DownloadFile streams file bytes from the file's viewUrl using the same auth // as API calls. Writes into dst. When the portal serves the file from its stale // AWS S3 consumer, the bytes are fetched from the local MinIO store instead // (see storage_fallback.go). func (c *Client) DownloadFile(ctx context.Context, fileID string, dst io.Writer) (int64, error) { f, err := c.GetFile(ctx, fileID) if err != nil { return 0, err } return c.downloadFileEntry(ctx, f, dst) } func (c *Client) resolveAPIURL(ref string) string { ref = strings.TrimSpace(ref) if ref == "" { return ref } // Rewrite any host to the configured API base so downloads stay on the // internal network and keep the Authorization header (no cross-host // redirect that would strip it). Scheme-relative URLs are handled too. if strings.HasPrefix(ref, "//") { ref = "http:" + ref } if u, err := url.Parse(ref); err == nil && u.IsAbs() { if base, err2 := url.Parse(c.baseURL()); err2 == nil { u.Scheme = base.Scheme u.Host = base.Host return u.String() } } base := c.baseURL() if strings.HasPrefix(ref, "/") { u, err := url.Parse(base) if err != nil { return base + ref } u.Path = "" u.RawQuery = "" u.Fragment = "" return strings.TrimRight(u.String(), "/") + ref } return base + "/" + strings.TrimPrefix(ref, "/") } func decodeResponseFileEntry(raw json.RawMessage) (*FileEntry, error) { var env struct { Response *FileEntry `json:"response"` } if err := json.Unmarshal(raw, &env); err != nil { return nil, err } if env.Response == nil { return nil, fmt.Errorf("empty file response") } return env.Response, nil } // FileEntryNumericID returns the file id as int64, or 0 if missing/invalid. func FileEntryNumericID(f *FileEntry) int64 { if f == nil || f.ID == nil { return 0 } n, err := f.ID.Int64() if err != nil { return 0 } return n } // FileEntryTitle returns the title or empty string. func FileEntryTitle(f *FileEntry) string { if f == nil || f.Title == nil { return "" } return *f.Title } // SafeLocalFileName sanitizes a server title for use as a local filename. func SafeLocalFileName(title string) string { title = strings.TrimSpace(title) if title == "" { return "download" } base := path.Base(title) base = strings.ReplaceAll(base, "\x00", "") if base == "." || base == "/" { return "download" } return base }