Release Please / Release Please (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 4s
Tests / Test (Go stable) (pull_request) Successful in 1m15s
Tests / Test (Go 1.25) (pull_request) Successful in 1m22s
474 lines
15 KiB
Go
474 lines
15 KiB
Go
package onlyoffice
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"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,
|
|
}
|
|
out, err := c.putJSONObject(ctx, "/api/2.0/files/fileops/move.json", body)
|
|
if err != nil {
|
|
out, err = c.putJSONObject(ctx, "/api/2.0/files/fileops/move", body)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if raw, merr := json.Marshal(out); merr == nil {
|
|
if ferr := fileopsError(raw); ferr != nil {
|
|
return nil, ferr
|
|
}
|
|
}
|
|
return out, err
|
|
}
|
|
|
|
// 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.
|
|
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")
|
|
}
|
|
uploadPath := fmt.Sprintf("/api/2.0/files/%s/update", url.PathEscape(fileID))
|
|
raw, err := c.uploadMultipart(ctx, uploadPath, "file", localPath)
|
|
if err != nil {
|
|
uploadPath = fmt.Sprintf("/api/2.0/files/%s/update.json", url.PathEscape(fileID))
|
|
raw, err = c.uploadMultipart(ctx, uploadPath, "file", localPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return decodeResponseFileEntry(raw)
|
|
}
|
|
|
|
// 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
|
|
}
|