Files
go-onlyoffice/files.go
T
eSlider e03fd62200 feat(files): project/task Documents API + oo projects|tasks files
Library (files.go):
- Types: FileEntry, FolderEntry, ProjectFilesResponse.
- GetProjectFiles, GetTaskFiles, GetFile, UploadProjectFile (POST .../upload),
  AttachFilesToTask (POST form files=), UploadTaskFile (upload+attach),
  DetachTaskFile (DELETE ?fileid=), RenameFile, DeleteFiles, DownloadFile.
- Helpers: FileEntryNumericID, FileEntryTitle, SafeLocalFileName, projectIDFromTaskMap.

http.go: putJSON for JSON PUT bodies.

CLI: oo projects files {list,upload,download,rename,delete}, oo tasks files
{list,upload,detach}; projects_files.go + tasks_files.go.

Tests: files_integration_test.go (live), files_test.go + testdata (decode).

Docs: CHANGELOG 0.6.0, README, AGENTS.md, cmd/oo/main.go tree.
Made-with: Cursor
2026-04-24 16:28:39 +01:00

385 lines
11 KiB
Go

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)
}
// 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)
}
type deleteFilesBody struct {
FileIDs []int `json:"fileIds"`
FolderIDs []int `json:"folderIds"`
}
// DeleteFiles permanently deletes files by numeric id (Documents module).
func (c *Client) DeleteFiles(ctx context.Context, fileIDs []int) error {
if len(fileIDs) == 0 {
return fmt.Errorf("no file ids to delete")
}
body := deleteFilesBody{FileIDs: fileIDs, FolderIDs: nil}
_, err := c.putJSON(ctx, "/api/2.0/files/fileops/delete.json", body)
if err != nil {
_, err = c.putJSON(ctx, "/api/2.0/files/fileops/delete", body)
}
return err
}
// DownloadFile streams file bytes from the file's viewUrl using the same auth
// as API calls. Writes into dst.
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
}
if f.ViewURL == nil || *f.ViewURL == "" {
return 0, fmt.Errorf("file %s has no viewUrl", fileID)
}
downloadURL := c.resolveAPIURL(*f.ViewURL)
auth, err := c.authHeader()
if err != nil {
return 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return 0, err
}
req.Header.Set("Authorization", auth)
resp, err := c.client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return 0, fmt.Errorf("GET viewUrl: %d %s", resp.StatusCode, truncate(string(b), 400))
}
n, err := io.Copy(dst, resp.Body)
return n, err
}
func (c *Client) resolveAPIURL(ref string) string {
ref = strings.TrimSpace(ref)
if ref == "" {
return ref
}
if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") {
return ref
}
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
}