Files
go-onlyoffice/files.go
T
eSlider af8e3b053a
Release Please / Release Please (push) Skipped
Release / GoReleaser (push) Skipped
Tests / Secret scan (gitleaks) (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Tests / Secret scan (gitleaks) (pull_request) Successful in 4s
Tests / Test (Go 1.25) (pull_request) Successful in 1m22s
Tests / Test (Go stable) (pull_request) Successful in 1m21s
feat(oo): kontoblatt bulk tools, oo dav, update, retry (#22)
2026-09-14 21:03:39 +00:00

496 lines
15 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)
}
// 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.
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
}
// 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
}